Skip to main content

agentmux_srv/muxbus/
wan_lease.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! One live instance per agent across the WAN — the client side
5//! (`docs/specs/SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md` Phase 5, §4.5).
6//!
7//! The muxbus relay holds one lease per agent. This instance claims it for every agent it polls
8//! jekts for, renews it while the agent stays registered, and releases it
9//! when the agent goes away. While another instance holds it, the relay
10//! answers this instance's pending pulls with 409 — so it cannot consume the
11//! other instance's jekts — and this module reports the agent as held
12//! elsewhere, which fences the local holder (the newcomer yields, D1).
13//!
14//! Fails open everywhere (D2): a relay without the lease routes (404), an
15//! unreachable relay or a transient error never stops delivery or spawning.
16
17use std::collections::HashMap;
18use std::sync::{LazyLock, Mutex};
19use std::time::{Duration, Instant};
20
21/// How often a held lease is renewed. The relay's TTL is 60 s.
22pub(crate) const RENEW_EVERY: Duration = Duration::from_secs(20);
23
24/// A relay answering 404 on the lease routes predates them: stop asking for
25/// this long.
26const UNSUPPORTED_BACKOFF: Duration = Duration::from_secs(600);
27
28/// How long a "held elsewhere" answer stays good enough to refuse a spawn
29/// without asking again.
30const HELD_ELSEWHERE_FRESH: Duration = Duration::from_secs(90);
31
32/// This AgentMux instance's id at the relay: host + channel. Stable across
33/// restarts of the same install, distinct between two installs on one host.
34pub(crate) fn instance_id() -> String {
35    format!(
36        "{}/{}",
37        crate::backend::reactive::registry::local_host_label(),
38        crate::backend::reactive::registry::local_channel_id()
39    )
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub(crate) enum Outcome {
44    /// This instance holds the agent's lease.
45    Held,
46    /// Another instance holds it; the text describes where.
47    HeldElsewhere(String),
48    /// No answer (relay unreachable, too old, an error): fail open.
49    Unknown,
50}
51
52#[derive(Default)]
53struct Entry {
54    held: bool,
55    last_ok: Option<Instant>,
56    elsewhere: Option<(String, Instant)>,
57    /// When a Take over last cleared `elsewhere`: a 409 to a request that
58    /// started before this is stale ([`note_not_holder_since`]).
59    forgotten_at: Option<Instant>,
60}
61
62static STATE: LazyLock<Mutex<HashMap<String, Entry>>> = LazyLock::new(Default::default);
63/// One claim, renewal or release at a time per agent. Without it a
64/// release still in flight (the RemoveAgent arm's runs detached, after a
65/// token refresh) could land after the agent was subscribed and claimed
66/// again, and release that fresh claim (Codex P2 on #3897).
67static OPS: LazyLock<Mutex<HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> = LazyLock::new(Default::default);
68
69fn op_lock(agent_id: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
70    OPS.lock().unwrap_or_else(|e| e.into_inner()).entry(key(agent_id)).or_default().clone()
71}
72
73static UNSUPPORTED_UNTIL: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(Default::default);
74
75fn key(agent_id: &str) -> String {
76    agent_id.trim().to_lowercase()
77}
78
79/// `Some(where)` when a recent answer said another instance holds `agent`.
80/// The early admission check refuses on it without a network round trip.
81pub(crate) fn held_elsewhere(agent: &str) -> Option<String> {
82    let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
83    let (desc, at) = state.get(&key(agent))?.elsewhere.clone()?;
84    (at.elapsed() < HELD_ELSEWHERE_FRESH).then_some(desc)
85}
86
87/// Drop a cached "held elsewhere" answer for `agent` — after a Take over
88/// freed it, so the retried turn isn't refused from the cache for up to
89/// [`HELD_ELSEWHERE_FRESH`] (Codex P1 on #3899).
90pub(crate) async fn forget_elsewhere(agent: &str) {
91    // Under the per-agent op lock: a lease check already in flight records
92    // its answer first, so a late pre-release 409 can't restore the
93    // refusal after it was forgotten (Codex P2 on #3903).
94    let op = op_lock(agent);
95    let _op = op.lock().await;
96    let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
97    let e = state.entry(key(agent)).or_default();
98    e.elsewhere = None;
99    e.forgotten_at = Some(Instant::now());
100}
101
102/// Is a [`describe_holder`] description about this computer (another
103/// AgentMux instance on the same host, e.g. a second channel)?
104pub(crate) fn holder_on_this_computer(desc: &str) -> bool {
105    desc == THIS_COMPUTER || desc.starts_with("this computer, ")
106}
107
108const THIS_COMPUTER: &str = "this computer";
109
110/// The channel of another instance on this computer that, per a recent
111/// relay answer, holds `agent` — what Take over needs to find it.
112pub(crate) fn held_on_this_computer_by(agent: &str) -> Option<String> {
113    let desc = held_elsewhere(agent)?;
114    if !holder_on_this_computer(&desc) {
115        return None;
116    }
117    desc.split(", ").find_map(|part| part.strip_prefix("channel ")).map(str::to_string)
118}
119
120/// Where the holder is when a recent relay answer put `agent` on another
121/// computer (a [`describe_holder`] description), else `None`.
122pub(crate) fn held_on_another_computer(agent: &str) -> Option<String> {
123    held_elsewhere(agent).filter(|desc| !desc.is_empty() && !holder_on_this_computer(desc))
124}
125
126/// Describe the relay's `held_by` for a refusal: "computer X, channel Y, vZ".
127pub(crate) fn describe_holder(held_by: &serde_json::Value) -> String {
128    let field = |k: &str| held_by.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
129    let mut parts = Vec::new();
130    let host = field("host");
131    if !host.is_empty() {
132        // The relay only knows the holder's host label; the same label as
133        // ours is another instance here, not another computer.
134        if host.eq_ignore_ascii_case(&crate::backend::reactive::registry::local_host_label()) {
135            parts.push(THIS_COMPUTER.to_string());
136        } else {
137            parts.push(format!("computer {host}"));
138        }
139    }
140    let channel = field("channel");
141    if !channel.is_empty() {
142        parts.push(format!("channel {channel}"));
143    }
144    let version = field("version");
145    if !version.is_empty() {
146        parts.push(format!("v{version}"));
147    }
148    parts.join(", ")
149}
150
151fn record(agent_id: &str, outcome: &Outcome) {
152    let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
153    let e = state.entry(key(agent_id)).or_default();
154    match outcome {
155        Outcome::Held => {
156            e.held = true;
157            e.last_ok = Some(Instant::now());
158            e.elsewhere = None;
159        }
160        Outcome::HeldElsewhere(desc) => {
161            e.held = false;
162            e.last_ok = None;
163            e.elsewhere = Some((desc.clone(), Instant::now()));
164        }
165        Outcome::Unknown => {}
166    }
167}
168
169/// [`note_not_holder`] for a request that started at `started`: `None`
170/// (nothing recorded, and the caller must not fence) when a Take over
171/// cleared the refusal after the request started — its 409 predates the
172/// holder letting go (Codex P1 on #3908). Pulls and acks don't take the op
173/// lock, so ordering by start time is what keeps them from restoring it.
174pub(crate) fn note_not_holder_since(agent_id: &str, body: &serde_json::Value, started: Instant) -> Option<String> {
175    let desc = body.get("held_by").map(describe_holder).unwrap_or_default();
176    // Check and write under one STATE lock (ReAgent P1 on #3908): with
177    // two, a forget_elsewhere in between would be overwritten.
178    let mut state = STATE.lock().unwrap_or_else(|e| e.into_inner());
179    let e = state.entry(key(agent_id)).or_default();
180    if e.forgotten_at.is_some_and(|at| at > started) {
181        return None;
182    }
183    e.held = false;
184    e.last_ok = None;
185    e.elsewhere = Some((desc.clone(), Instant::now()));
186    Some(desc)
187}
188
189/// Whether this instance currently records `agent_id`'s lease as held.
190pub(crate) fn is_held(agent_id: &str) -> bool {
191    STATE.lock().unwrap_or_else(|e| e.into_inner()).get(&key(agent_id)).is_some_and(|e| e.held)
192}
193
194#[cfg(test)]
195pub(crate) fn mark_held_for_test(agent_id: &str) {
196    record(agent_id, &Outcome::Held);
197}
198
199/// Note a 409 `not_holder` the relay returned on a pending pull or an ack.
200#[cfg(test)]
201pub(crate) fn note_not_holder(agent_id: &str, body: &serde_json::Value) -> String {
202    let desc = body.get("held_by").map(describe_holder).unwrap_or_default();
203    record(agent_id, &Outcome::HeldElsewhere(desc.clone()));
204    desc
205}
206
207/// Claim or renew `agent_id`'s WAN lease, at most every [`RENEW_EVERY`].
208/// `base` is the relay's REST base URL ([`super::relay::rest_base_url`]).
209pub(crate) async fn ensure(
210    base: &str,
211    agent_id: &str,
212    token: &str,
213    http: &reqwest::Client,
214    still_subscribed: impl Fn() -> bool,
215) -> Outcome {
216    if UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now()) {
217        return Outcome::Unknown;
218    }
219    let op = op_lock(agent_id);
220    let _op = op.lock().await;
221    // Callers work from a copy of the subscription; a Take over may have
222    // released the agent since. Re-checked under the op lock so it
223    // can't claim the lease straight back (Codex P2 on #3899).
224    if !still_subscribed() {
225        return Outcome::Unknown;
226    }
227    let renew_due = {
228        let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
229        match state.get(&key(agent_id)) {
230            Some(e) if e.held => match e.last_ok {
231                Some(at) if at.elapsed() < RENEW_EVERY => return Outcome::Held,
232                _ => true,
233            },
234            _ => false,
235        }
236    };
237    let outcome = if renew_due {
238        match call(base, agent_id, token, http, "/agents/lease/renew").await {
239            // Nobody holds it any more (`held_by: null`): claim again.
240            Some((409, body)) if body.get("held_by").is_some_and(|h| h.is_null()) => {
241                claim_outcome(call(base, agent_id, token, http, "/agents/lease").await)
242            }
243            other => claim_outcome(other),
244        }
245    } else {
246        claim_outcome(call(base, agent_id, token, http, "/agents/lease").await)
247    };
248    record(agent_id, &outcome);
249    outcome
250}
251
252fn claim_outcome(resp: Option<(u16, serde_json::Value)>) -> Outcome {
253    match resp {
254        Some((200..=299, _)) => Outcome::Held,
255        Some((409, body)) => Outcome::HeldElsewhere(body.get("held_by").map(describe_holder).unwrap_or_default()),
256        Some((404, _)) | Some((405, _)) => {
257            *UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now() + UNSUPPORTED_BACKOFF);
258            tracing::info!("wan_lease: relay has no agent-lease routes yet — WAN tier inactive for now");
259            Outcome::Unknown
260        }
261        _ => Outcome::Unknown,
262    }
263}
264
265/// POST `path` for `agent_id`; `(status, json body)` or `None` on transport error.
266async fn call(base: &str, agent_id: &str, token: &str, http: &reqwest::Client, path: &str) -> Option<(u16, serde_json::Value)> {
267    let body = serde_json::json!({
268        "agent_id": agent_id,
269        "instance": instance_id(),
270        "host": crate::backend::reactive::registry::local_host_label(),
271        "channel": crate::backend::reactive::registry::local_channel_id(),
272        "version": env!("CARGO_PKG_VERSION"),
273    });
274    post(base, path, token, http, Some(agent_id), &body).await
275}
276
277/// POST `body` to `path`, with `X-Agent-ID: agent_id` when there is one;
278/// `(status, json body)` or `None` on transport error.
279async fn post(
280    base: &str,
281    path: &str,
282    token: &str,
283    http: &reqwest::Client,
284    agent_id: Option<&str>,
285    body: &serde_json::Value,
286) -> Option<(u16, serde_json::Value)> {
287    let mut req = http
288        .post(format!("{base}{path}"))
289        .header("Authorization", format!("Bearer {token}"))
290        .json(body)
291        .timeout(Duration::from_secs(10));
292    if let Some(agent_id) = agent_id {
293        req = req.header("X-Agent-ID", agent_id);
294    }
295    match req.send().await {
296        Ok(r) => {
297            let status = r.status().as_u16();
298            let json = r.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
299            Some((status, json))
300        }
301        Err(e) => {
302            tracing::debug!(agent_id, path, error = %e, "wan_lease: relay unreachable");
303            None
304        }
305    }
306}
307
308/// Renew, in one request (`POST /agents/lease/renew-batch`), the lease of
309/// every agent in `agents` this instance holds and is due to renew, instead
310/// of one request per agent every [`RENEW_EVERY`]. Returns each renewed
311/// agent's outcome, keyed by its lowercased name. An agent it leaves out (not
312/// held, not due, fewer than two due, a relay without the route, a failed
313/// request, an `unavailable` answer) is the caller's to [`ensure`] on its own,
314/// exactly as before.
315pub(crate) async fn renew_held_batch(
316    base: &str,
317    agents: &[String],
318    token: &str,
319    http: &reqwest::Client,
320    still_subscribed: impl Fn(&str) -> bool,
321) -> HashMap<String, Outcome> {
322    let mut outcomes = HashMap::new();
323    let lease_tier_off = UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now());
324    if lease_tier_off || super::relay::batch_routes_missing(base) {
325        return outcomes;
326    }
327    let mut due: Vec<String> = {
328        let state = STATE.lock().unwrap_or_else(|e| e.into_inner());
329        agents
330            .iter()
331            .map(|a| key(a))
332            .filter(|k| {
333                state
334                    .get(k)
335                    .is_some_and(|e| e.held && e.last_ok.is_none_or(|at| at.elapsed() >= RENEW_EVERY))
336            })
337            .collect()
338    };
339    due.sort();
340    due.dedup();
341    if due.len() < 2 {
342        return outcomes;
343    }
344    // Every agent's op lock, taken in name order so two batches can't
345    // deadlock, and held across the request as `ensure` holds its own: a
346    // Take over or release for one of them waits, then sees the result.
347    let locks: Vec<_> = due.iter().map(|a| op_lock(a)).collect();
348    let mut _guards = Vec::with_capacity(locks.len());
349    for lock in &locks {
350        _guards.push(lock.lock().await);
351    }
352    due.retain(|a| still_subscribed(a));
353    for chunk in due.chunks(super::relay::MAX_BATCH_AGENTS) {
354        let body = serde_json::json!({ "instance": instance_id(), "agent_ids": chunk });
355        let results = match post(base, "/agents/lease/renew-batch", token, http, None, &body).await {
356            Some((404, _)) | Some((405, _)) => {
357                super::relay::note_batch_routes_missing(base);
358                return outcomes;
359            }
360            Some((200..=299, body)) => body.get("results").cloned().unwrap_or_default(),
361            _ => continue,
362        };
363        for agent in chunk {
364            let Some(entry) = results.get(agent.as_str()) else { continue };
365            let outcome = if entry.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
366                Outcome::Held
367            } else if entry.get("error").and_then(serde_json::Value::as_str) != Some("not_holder") {
368                continue; // `unavailable`: renewed on its own instead
369            } else if entry.get("held_by").is_some_and(serde_json::Value::is_null) {
370                // Nobody holds it any more: claim again, as `ensure` does.
371                claim_outcome(call(base, agent, token, http, "/agents/lease").await)
372            } else {
373                Outcome::HeldElsewhere(entry.get("held_by").map(describe_holder).unwrap_or_default())
374            };
375            record(agent, &outcome);
376            outcomes.insert(agent.clone(), outcome);
377        }
378    }
379    outcomes
380}
381
382/// Release `agent_id`'s lease (best effort) and forget it — unless
383/// `still_gone` says it was subscribed again by the time this runs, in
384/// which case this returns `false` (it did not let go).
385pub(crate) async fn release(
386    base: &str,
387    agent_id: &str,
388    token: &str,
389    http: &reqwest::Client,
390    still_gone: impl Fn() -> bool,
391) -> bool {
392    let op = op_lock(agent_id);
393    let _op = op.lock().await;
394    // Checked under the op lock: an agent subscribed again since the
395    // release was queued keeps its (possibly fresh) claim.
396    if !still_gone() {
397        return false;
398    }
399    let was_held = STATE
400        .lock()
401        .unwrap_or_else(|e| e.into_inner())
402        .remove(&key(agent_id))
403        .is_some_and(|e| e.held);
404    if was_held {
405        let _ = call(base, agent_id, token, http, "/agents/lease/release").await;
406    }
407    true
408}
409
410/// Take over's relay step, after the holder answered its release request:
411/// claim `agent_id`'s lease now, and if the relay still names an instance
412/// **on this computer**, move it with `POST /agents/lease/take` (the relay
413/// allows that only within one account). A holder older than #3899 answers
414/// the release but keeps renewing the lease for as long as it runs, so
415/// without this the new pane is fenced seconds after it starts
416/// (`docs/investigations/INVESTIGATION_TAKE_OVER_OLD_HOLDER_RELAY_LEASE_2026_09_27.md`).
417/// Another computer is never taken from: Take over only works on this one.
418pub(crate) async fn take_over(base: &str, agent_id: &str, token: &str, http: &reqwest::Client) -> Outcome {
419    let op = op_lock(agent_id);
420    let _op = op.lock().await;
421    let claimed = claim_outcome(call(base, agent_id, token, http, "/agents/lease").await);
422    let outcome = match claimed {
423        Outcome::HeldElsewhere(desc) if holder_on_this_computer(&desc) => {
424            match call(base, agent_id, token, http, "/agents/lease/take").await {
425                Some((200..=299, _)) => {
426                    tracing::info!(agent_id, from = %desc, "wan_lease: took the lease over from an instance on this computer");
427                    Outcome::Held
428                }
429                // Refused (409) keeps the holder the relay names now; a relay
430                // without the route (404/405) or no answer keeps the one we
431                // already have. Neither turns the lease tier off.
432                Some((409, body)) => claim_outcome(Some((409, body))),
433                other => {
434                    tracing::warn!(agent_id, answer = ?other.map(|(s, _)| s), "wan_lease: the relay would not take the lease over");
435                    Outcome::HeldElsewhere(desc)
436                }
437            }
438        }
439        other => other,
440    };
441    record(agent_id, &outcome);
442    outcome
443}
444
445/// The relay's answer to "which install holds `agent`'s lease?" —
446/// `GET /agents/lease/:agent_id`
447/// (`docs/plans/PLAN_JEKT_LOCAL_FIRST_ROUTING_2026_10_02.md` §7.1). Asked by
448/// a *sender* about a message's target, so it never touches this
449/// instance's own lease state ([`record`], admission): a read only.
450#[derive(Debug, Clone, PartialEq, Eq)]
451pub(crate) enum Holder {
452    /// No live lease.
453    Free,
454    /// This instance holds it.
455    Yours,
456    /// Another install of the same account holds it: the agent is live there.
457    Other(String),
458    /// A lease the relay won't describe (another account, or none), or a
459    /// relay-side error.
460    Unknown,
461    /// The relay predates the route (404/405).
462    Unsupported,
463    /// No answer at all.
464    Unreachable,
465}
466
467/// How long an answer is reused. Shorter than the lease TTL (60 s), so a
468/// lease that lapsed or moved is seen within a renewal or two.
469const HOLDER_FRESH: Duration = Duration::from_secs(20);
470
471static HOLDER_CACHE: LazyLock<Mutex<HashMap<String, (Holder, Instant)>>> = LazyLock::new(Default::default);
472static HOLDER_UNSUPPORTED_UNTIL: LazyLock<Mutex<Option<Instant>>> = LazyLock::new(Default::default);
473
474/// Ask the relay who holds `agent`'s lease, as `caller` (the message's
475/// sender, whose credential `token` is). Cached for [`HOLDER_FRESH`];
476/// "unreachable" is never cached.
477pub(crate) async fn holder(base: &str, agent: &str, caller: &str, token: &str, http: &reqwest::Client) -> Holder {
478    if HOLDER_UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()).is_some_and(|t| t > Instant::now()) {
479        return Holder::Unsupported;
480    }
481    if let Some((answer, at)) = HOLDER_CACHE.lock().unwrap_or_else(|e| e.into_inner()).get(&key(agent)) {
482        if at.elapsed() < HOLDER_FRESH {
483            return answer.clone();
484        }
485    }
486    let Ok(mut url) = url::Url::parse(base) else {
487        return Holder::Unknown;
488    };
489    if let Ok(mut path) = url.path_segments_mut() {
490        path.pop_if_empty().extend(["agents", "lease", agent.trim()]);
491    }
492    let resp = http
493        .get(url)
494        .header("Authorization", format!("Bearer {token}"))
495        .header("X-Agent-ID", caller)
496        .header("X-Agent-Instance", instance_id())
497        .timeout(Duration::from_secs(5))
498        .send()
499        .await;
500    let answer = match resp {
501        Ok(r) => {
502            let status = r.status().as_u16();
503            let body = r.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
504            holder_from(status, &body)
505        }
506        Err(e) => {
507            tracing::debug!(agent, error = %e, "wan_lease: holder query unreachable");
508            return Holder::Unreachable;
509        }
510    };
511    if answer == Holder::Unsupported {
512        *HOLDER_UNSUPPORTED_UNTIL.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now() + UNSUPPORTED_BACKOFF);
513        tracing::info!("wan_lease: relay has no lease holder query yet — routing as before");
514    }
515    HOLDER_CACHE
516        .lock()
517        .unwrap_or_else(|e| e.into_inner())
518        .insert(key(agent), (answer.clone(), Instant::now()));
519    answer
520}
521
522fn holder_from(status: u16, body: &serde_json::Value) -> Holder {
523    match status {
524        200..=299 => match body.get("state").and_then(|s| s.as_str()) {
525            Some("free") => Holder::Free,
526            Some("yours") => Holder::Yours,
527            Some("other") => Holder::Other(body.get("held_by").map(describe_holder).unwrap_or_default()),
528            _ => Holder::Unknown,
529        },
530        404 | 405 => Holder::Unsupported,
531        _ => Holder::Unknown,
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    /// The holder query on the wire: `GET {base}/agents/lease/{agent}` with
540    /// the sender's credential, its agent id and this instance's id; the
541    /// answer is reused for HOLDER_FRESH, and "unreachable" is not cached.
542    #[tokio::test]
543    async fn the_holder_query_asks_once_and_names_the_caller() {
544        let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(String, Option<String>, Option<String>, Option<String>)>::new()));
545        let sink = hits.clone();
546        let app = axum::Router::new().route(
547            "/agents/lease/:agent",
548            axum::routing::get(
549                move |axum::extract::Path(agent): axum::extract::Path<String>, headers: axum::http::HeaderMap| {
550                    let sink = sink.clone();
551                    async move {
552                        let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(str::to_string);
553                        sink.lock().unwrap().push((agent, get("authorization"), get("x-agent-id"), get("x-agent-instance")));
554                        axum::Json(serde_json::json!({ "state": "other", "held_by": { "host": "area54-holder-test", "channel": "stable" } }))
555                    }
556                },
557            ),
558        );
559        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
560        let addr = listener.local_addr().unwrap();
561        tokio::spawn(async move {
562            let _ = axum::serve(listener, app).await;
563        });
564        let base = format!("http://{addr}");
565        let http = reqwest::Client::new();
566        let agent = format!("HolderTest-{}", &uuid::Uuid::new_v4().to_string()[..8]);
567
568        let first = holder(&base, &agent, "lark", "tok", &http).await;
569        assert!(matches!(&first, Holder::Other(d) if d.contains("area54-holder-test")), "{first:?}");
570        let again = holder(&base, &agent, "lark", "tok", &http).await;
571        assert_eq!(again, first);
572        let seen = hits.lock().unwrap().clone();
573        assert_eq!(seen.len(), 1, "the second answer came from the cache: {seen:?}");
574        let (path_agent, auth, caller, instance) = &seen[0];
575        assert_eq!(path_agent, &agent);
576        assert_eq!(auth.as_deref(), Some("Bearer tok"));
577        assert_eq!(caller.as_deref(), Some("lark"));
578        assert_eq!(instance.as_deref(), Some(instance_id().as_str()));
579
580        // Nothing listening: unreachable, and asked again next time.
581        let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
582        let dead = format!("http://{}", closed.local_addr().unwrap());
583        drop(closed);
584        let lost = format!("HolderLost-{}", &uuid::Uuid::new_v4().to_string()[..8]);
585        assert_eq!(holder(&dead, &lost, "lark", "tok", &http).await, Holder::Unreachable);
586        assert!(HOLDER_CACHE.lock().unwrap().get(&key(&lost)).is_none());
587    }
588
589    /// The holder query's answers. Anything the relay
590    /// does not say plainly is `Unknown`, which routes as before.
591    #[test]
592    fn holder_answers_map_to_routes() {
593        use serde_json::json;
594        assert_eq!(holder_from(200, &json!({ "state": "free" })), Holder::Free);
595        assert_eq!(holder_from(200, &json!({ "state": "yours" })), Holder::Yours);
596        let other = holder_from(200, &json!({ "state": "other", "held_by": { "host": "area54", "channel": "stable", "version": "0.59.5" } }));
597        assert!(matches!(&other, Holder::Other(d) if d.contains("area54")), "{other:?}");
598        assert_eq!(holder_from(200, &json!({ "state": "unknown" })), Holder::Unknown);
599        assert_eq!(holder_from(200, &json!({ "state": "weird" })), Holder::Unknown);
600        assert_eq!(holder_from(200, &serde_json::Value::Null), Holder::Unknown);
601        assert_eq!(holder_from(404, &serde_json::Value::Null), Holder::Unsupported);
602        assert_eq!(holder_from(405, &serde_json::Value::Null), Holder::Unsupported);
603        for status in [401, 403, 429, 500] {
604            assert_eq!(holder_from(status, &serde_json::Value::Null), Holder::Unknown, "{status}");
605        }
606    }
607
608    // Codex P1 on #3899: after a successful Take over the requester's own
609    // cached refusal still refused the retried turn for up to 90 s.
610    #[tokio::test]
611    async fn forgetting_a_refusal_lets_the_next_turn_through() {
612        let agent = format!("agent-{}", uuid::Uuid::new_v4());
613        note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": "desk" } }));
614        assert!(held_elsewhere(&agent).is_some());
615        forget_elsewhere(&agent.to_uppercase()).await;
616        assert_eq!(held_elsewhere(&agent), None);
617    }
618
619    // Codex P1 on #3908: a pull or ack answered 409 before the holder let
620    // go, but processed after the refusal was forgotten, restored it (and
621    // could fence the new pane). Only answers to requests started after
622    // the forget count.
623    #[tokio::test]
624    async fn a_409_to_a_request_from_before_a_take_over_is_ignored() {
625        let agent = format!("agent-{}", uuid::Uuid::new_v4());
626        let body = serde_json::json!({ "held_by": { "host": "desk" } });
627        let started = Instant::now();
628        tokio::time::sleep(Duration::from_millis(5)).await;
629        forget_elsewhere(&agent).await;
630        assert_eq!(note_not_holder_since(&agent, &body, started), None, "stale: not recorded, not fenced");
631        assert_eq!(held_elsewhere(&agent), None);
632
633        let later = Instant::now();
634        assert!(note_not_holder_since(&agent, &body, later).is_some(), "a fresh 409 still counts");
635        assert!(held_elsewhere(&agent).is_some());
636    }
637
638    // Codex P2 on #3903: a lease check already in flight could record a
639    // delayed pre-release 409 after the refusal was forgotten, restoring it
640    // for another 90 s. Forgetting waits for it.
641    #[tokio::test]
642    async fn forgetting_a_refusal_waits_for_a_lease_check_in_flight() {
643        let agent = format!("agent-{}", uuid::Uuid::new_v4());
644        let lock = op_lock(&agent);
645        let check = lock.lock().await;
646        let a = agent.clone();
647        let forget = tokio::spawn(async move { forget_elsewhere(&a).await });
648        tokio::time::sleep(Duration::from_millis(50)).await;
649        assert!(!forget.is_finished(), "forgetting waits for the check");
650        // The in-flight check lands its (stale) 409.
651        note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": "desk" } }));
652        drop(check);
653        tokio::time::timeout(Duration::from_secs(5), forget).await.unwrap().unwrap();
654        assert_eq!(held_elsewhere(&agent), None, "the stale refusal is gone");
655    }
656
657    // Codex P2 on #3899: a lease tick that snapshotted the agent before a
658    // Take over released it would claim the lease straight back.
659    #[tokio::test]
660    async fn a_renewal_for_an_agent_no_longer_subscribed_claims_nothing() {
661        let hits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
662        let counter = hits.clone();
663        let app = axum::Router::new().fallback(move || {
664            let counter = counter.clone();
665            async move {
666                counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
667                axum::Json(serde_json::json!({ "ok": true, "epoch": 1 }))
668            }
669        });
670        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
671        let base = format!("http://{}", listener.local_addr().unwrap());
672        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
673        let agent = format!("agent-{}", uuid::Uuid::new_v4());
674        let http = reqwest::Client::new();
675
676        assert_eq!(ensure(&base, &agent, "", &http, || false).await, Outcome::Unknown);
677        assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "no claim for an unsubscribed agent");
678        assert_eq!(ensure(&base, &agent, "", &http, || true).await, Outcome::Held);
679        assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 1);
680    }
681
682    // Codex P2 (third pass) on #3897: the RemoveAgent arm's release runs
683    // detached; if the agent was subscribed and claimed again meanwhile, it
684    // released that fresh claim and let another instance fence the agent.
685    #[tokio::test]
686    async fn a_release_skips_an_agent_subscribed_again() {
687        let agent = format!("agent-{}", uuid::Uuid::new_v4());
688        record(&agent, &Outcome::Held);
689        assert!(
690            !release("http://127.0.0.1:9", &agent, "", &reqwest::Client::new(), || false).await,
691            "reports that it did not let go"
692        );
693        let held = STATE.lock().unwrap().get(&key(&agent)).is_some_and(|e| e.held);
694        assert!(held, "the fresh claim is left alone");
695    }
696
697    #[tokio::test]
698    async fn a_release_waits_for_a_claim_in_progress_for_the_same_agent() {
699        let agent = format!("Agent-{}", uuid::Uuid::new_v4());
700        let lock = op_lock(&agent.to_lowercase());
701        let claim = lock.lock().await;
702        let a = agent.clone();
703        let release = tokio::spawn(async move {
704            super::release("http://127.0.0.1:9", &a, "", &reqwest::Client::new(), || true).await
705        });
706        tokio::time::sleep(Duration::from_millis(50)).await;
707        assert!(!release.is_finished(), "the release waits for the claim");
708        drop(claim);
709        tokio::time::timeout(Duration::from_secs(5), release).await.unwrap().unwrap();
710    }
711
712    #[test]
713    fn the_holder_is_described_without_account_or_instance() {
714        let held_by = serde_json::json!({ "host": "desk", "channel": "stable", "version": "0.58.0", "acquired_at": 1 });
715        assert_eq!(describe_holder(&held_by), "computer desk, channel stable, v0.58.0");
716        assert_eq!(describe_holder(&serde_json::Value::Null), "");
717    }
718
719    // Charlie 2026-09-26: 0.57.6 and a 0.57.7 build on the same computer;
720    // the refusal said "on another computer" about its own host.
721    #[test]
722    fn a_holder_on_this_computer_is_described_as_this_computer() {
723        let here = crate::backend::reactive::registry::local_host_label();
724        let held_by = serde_json::json!({ "host": here, "channel": "local-main-x", "version": "0.57.6" });
725        assert_eq!(describe_holder(&held_by), "this computer, channel local-main-x, v0.57.6");
726        assert!(holder_on_this_computer(&describe_holder(&held_by)));
727        assert!(!holder_on_this_computer("computer desk, channel stable, v0.58.0"));
728    }
729
730    // Take over needs the channel of a same-computer holder the relay
731    // reported, to find that instance and ask it to let go.
732    #[test]
733    fn a_same_computer_holder_gives_take_over_its_channel() {
734        let here = crate::backend::reactive::registry::local_host_label();
735        let agent = format!("agent-{}", uuid::Uuid::new_v4());
736        note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": here, "channel": "local-main-x", "version": "0.57.6" } }));
737        assert_eq!(held_on_this_computer_by(&agent).as_deref(), Some("local-main-x"));
738        assert_eq!(held_on_another_computer(&agent), None);
739
740        let other = format!("agent-{}", uuid::Uuid::new_v4());
741        note_not_holder(&other, &serde_json::json!({ "held_by": { "host": "desk", "channel": "stable" } }));
742        assert_eq!(held_on_this_computer_by(&other), None);
743        assert_eq!(held_on_another_computer(&other).as_deref(), Some("computer desk, channel stable"));
744
745        assert_eq!(held_on_this_computer_by("never-seen"), None);
746    }
747
748    /// A relay stub for [`take_over`]: `claim` and `take` answer with the
749    /// given status and body; each route counts its hits.
750    async fn lease_stub(
751        claim: (u16, serde_json::Value),
752        take: (u16, serde_json::Value),
753    ) -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
754        use std::sync::atomic::{AtomicUsize, Ordering};
755        let (claims, takes) = (std::sync::Arc::new(AtomicUsize::new(0)), std::sync::Arc::new(AtomicUsize::new(0)));
756        let (c, t) = (claims.clone(), takes.clone());
757        let answer = |(status, body): (u16, serde_json::Value)| {
758            (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
759        };
760        let app = axum::Router::new()
761            .route(
762                "/agents/lease",
763                axum::routing::post(move || {
764                    c.fetch_add(1, Ordering::SeqCst);
765                    let r = answer(claim.clone());
766                    async move { r }
767                }),
768            )
769            .route(
770                "/agents/lease/take",
771                axum::routing::post(move || {
772                    t.fetch_add(1, Ordering::SeqCst);
773                    let r = answer(take.clone());
774                    async move { r }
775                }),
776            );
777        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
778        let base = format!("http://{}", listener.local_addr().unwrap());
779        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
780        (base, claims, takes)
781    }
782
783    fn held_here(channel: &str) -> serde_json::Value {
784        let here = crate::backend::reactive::registry::local_host_label();
785        serde_json::json!({ "error": "held_by_other", "held_by": { "host": here, "channel": channel, "version": "0.57.6" } })
786    }
787
788    // narko 2026-09-27: a 0.57.6 holder answered the release but kept
789    // renewing the relay lease; Take over has to move it itself.
790    #[tokio::test]
791    async fn take_over_takes_a_lease_an_instance_on_this_computer_kept() {
792        use std::sync::atomic::Ordering;
793        let (base, claims, takes) =
794            lease_stub((409, held_here("local-main-old")), (200, serde_json::json!({ "ok": true, "epoch": 6 }))).await;
795        let agent = format!("agent-{}", uuid::Uuid::new_v4());
796        assert_eq!(take_over(&base, &agent, "", &reqwest::Client::new()).await, Outcome::Held);
797        assert_eq!((claims.load(Ordering::SeqCst), takes.load(Ordering::SeqCst)), (1, 1));
798        assert!(is_held(&agent), "recorded, so the lease tick renews it");
799        assert_eq!(held_elsewhere(&agent), None);
800    }
801
802    #[tokio::test]
803    async fn take_over_never_takes_from_another_computer() {
804        use std::sync::atomic::Ordering;
805        let other = serde_json::json!({ "error": "held_by_other", "held_by": { "host": "some-other-desk", "channel": "stable" } });
806        let (base, _, takes) = lease_stub((409, other), (200, serde_json::json!({ "ok": true, "epoch": 6 }))).await;
807        let agent = format!("agent-{}", uuid::Uuid::new_v4());
808        assert_eq!(
809            take_over(&base, &agent, "", &reqwest::Client::new()).await,
810            Outcome::HeldElsewhere("computer some-other-desk, channel stable".into())
811        );
812        assert_eq!(takes.load(Ordering::SeqCst), 0);
813    }
814
815    #[tokio::test]
816    async fn take_over_of_a_free_lease_is_a_plain_claim() {
817        use std::sync::atomic::Ordering;
818        let (base, _, takes) = lease_stub((200, serde_json::json!({ "ok": true, "epoch": 1 })), (500, serde_json::Value::Null)).await;
819        let agent = format!("agent-{}", uuid::Uuid::new_v4());
820        assert_eq!(take_over(&base, &agent, "", &reqwest::Client::new()).await, Outcome::Held);
821        assert_eq!(takes.load(Ordering::SeqCst), 0);
822    }
823
824    // A relay deployed before /agents/lease/take answers it 404. That must
825    // keep the holder's refusal, and must not switch the whole lease tier
826    // off the way a 404 on the claim route does.
827    #[tokio::test]
828    async fn take_over_on_a_relay_without_take_keeps_the_refusal_and_the_lease_tier() {
829        let (base, _, _) = lease_stub((409, held_here("local-main-old")), (404, serde_json::Value::Null)).await;
830        let agent = format!("agent-{}", uuid::Uuid::new_v4());
831        let outcome = take_over(&base, &agent, "", &reqwest::Client::new()).await;
832        assert!(matches!(&outcome, Outcome::HeldElsewhere(d) if holder_on_this_computer(d)), "{outcome:?}");
833        assert!(
834            UNSUPPORTED_UNTIL.lock().unwrap().is_none_or(|t| t <= Instant::now()),
835            "a missing take route is not a missing lease tier"
836        );
837    }
838
839    #[test]
840    fn claim_answers_map_to_outcomes() {
841        assert_eq!(claim_outcome(Some((200, serde_json::json!({ "ok": true, "epoch": 1 })))), Outcome::Held);
842        assert_eq!(
843            claim_outcome(Some((409, serde_json::json!({ "held_by": { "host": "desk" } })))),
844            Outcome::HeldElsewhere("computer desk".into())
845        );
846        assert_eq!(claim_outcome(None), Outcome::Unknown, "unreachable relay: fail open");
847        assert_eq!(claim_outcome(Some((500, serde_json::Value::Null))), Outcome::Unknown);
848    }
849
850    #[test]
851    fn a_not_holder_answer_is_remembered_for_the_early_check_and_cleared_by_a_grant() {
852        let agent = format!("agent-{}", uuid::Uuid::new_v4());
853        assert!(held_elsewhere(&agent).is_none());
854        let desc = note_not_holder(&agent.to_uppercase(), &serde_json::json!({ "held_by": { "host": "desk" } }));
855        assert_eq!(desc, "computer desk");
856        assert_eq!(held_elsewhere(&agent).as_deref(), Some("computer desk"), "keyed case-insensitively, like the relay");
857        record(&agent, &Outcome::Held);
858        assert!(held_elsewhere(&agent).is_none());
859    }
860
861    /// A relay stub recording `"METHOD /path"` of every request; `renew-batch`
862    /// answers `results`, the claim route grants.
863    async fn batch_stub(
864        renew_batch: (u16, serde_json::Value),
865    ) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
866        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
867        let log = seen.clone();
868        let app = axum::Router::new().fallback(move |req: axum::extract::Request| {
869            let (log, renew_batch) = (log.clone(), renew_batch.clone());
870            async move {
871                let line = format!("{} {}", req.method(), req.uri().path());
872                log.lock().unwrap().push(line.clone());
873                let (status, body) = match line.as_str() {
874                    "POST /agents/lease/renew-batch" => renew_batch,
875                    "POST /agents/lease" => (200, serde_json::json!({ "ok": true, "epoch": 2 })),
876                    _ => (404, serde_json::Value::Null),
877                };
878                (axum::http::StatusCode::from_u16(status).unwrap(), axum::Json(body))
879            }
880        });
881        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
882        let base = format!("http://{}", listener.local_addr().unwrap());
883        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
884        (base, seen)
885    }
886
887    /// Held, with its renewal due now.
888    fn held_and_due(agent: &str) {
889        STATE.lock().unwrap().insert(key(agent), Entry { held: true, ..Default::default() });
890    }
891
892    #[tokio::test]
893    async fn one_request_renews_every_held_lease_that_is_due() {
894        let [a, b, c, d] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
895        for agent in [&a, &b, &c, &d] {
896            held_and_due(agent);
897        }
898        let results = serde_json::json!({ "results": {
899            a.clone(): { "ok": true, "epoch": 1, "ttl_ms": 60000 },
900            b.clone(): { "ok": false, "error": "not_holder", "held_by": { "host": "desk", "channel": "stable" } },
901            c.clone(): { "ok": false, "error": "not_holder", "held_by": null },
902            d.clone(): { "ok": false, "error": "unavailable" },
903        }});
904        let (base, seen) = batch_stub((200, results)).await;
905        let agents = vec![a.to_uppercase(), b.clone(), c.clone(), d.clone()];
906
907        let got = renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await;
908
909        assert_eq!(got.get(&a), Some(&Outcome::Held), "keyed by the lowercased name");
910        assert_eq!(got.get(&b), Some(&Outcome::HeldElsewhere("computer desk, channel stable".into())));
911        assert_eq!(got.get(&c), Some(&Outcome::Held), "nobody held it: claimed again");
912        assert_eq!(got.get(&d), None, "unavailable: left to `ensure` on its own");
913        assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease/renew-batch", "POST /agents/lease"]);
914        assert_eq!(held_elsewhere(&b).as_deref(), Some("computer desk, channel stable"));
915        assert!(is_held(&a) && is_held(&c));
916    }
917
918    #[tokio::test]
919    async fn a_relay_without_the_batch_route_is_left_to_renew_one_by_one() {
920        let [a, b] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
921        held_and_due(&a);
922        held_and_due(&b);
923        let (base, seen) = batch_stub((404, serde_json::Value::Null)).await;
924        let agents = vec![a.clone(), b.clone()];
925
926        assert!(renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
927        assert!(super::super::relay::batch_routes_missing(&base));
928        assert!(
929            UNSUPPORTED_UNTIL.lock().unwrap().is_none_or(|t| t <= Instant::now()),
930            "a missing batch route is not a missing lease tier"
931        );
932        // Remembered: the next tick doesn't ask again.
933        assert!(renew_held_batch(&base, &agents, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
934        assert_eq!(seen.lock().unwrap().len(), 1);
935    }
936
937    #[tokio::test]
938    async fn the_batch_skips_a_single_due_agent_and_agents_no_longer_subscribed() {
939        let [a, b, fresh] = std::array::from_fn(|_| format!("agent-{}", uuid::Uuid::new_v4()));
940        held_and_due(&a);
941        held_and_due(&b);
942        record(&fresh, &Outcome::Held); // renewed just now: not due
943        let (base, seen) = batch_stub((200, serde_json::json!({ "results": {} }))).await;
944
945        let one_due = vec![a.clone(), fresh.clone()];
946        assert!(renew_held_batch(&base, &one_due, "tok", &reqwest::Client::new(), |_| true).await.is_empty());
947        assert!(seen.lock().unwrap().is_empty(), "one due agent: the single route costs the same");
948
949        let both = vec![a.clone(), b.clone()];
950        renew_held_batch(&base, &both, "tok", &reqwest::Client::new(), |agent| agent == a).await;
951        assert_eq!(*seen.lock().unwrap(), ["POST /agents/lease/renew-batch"], "asked, but only for {a}");
952    }
953
954    #[test]
955    fn the_instance_id_is_host_and_channel() {
956        let id = instance_id();
957        assert_eq!(id.matches('/').count(), 1, "{id}");
958        assert!(id.ends_with(&crate::backend::reactive::registry::local_channel_id()));
959    }
960}