Skip to main content

agentmux_srv/server/
agent_takeover.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Taking an agent over from another AgentMux instance on this host
5//! (`docs/specs/SPEC_AGENT_SINGLE_LIVE_INSTANCE_2026_09_24.md` Phase 2, §4.6).
6//!
7//! Two halves, both behind full `auth_key` auth:
8//!
9//! - `POST /api/v1/agent/takeover {block_id}` — on the instance the user is
10//!   in. Its pane was refused because another instance runs the agent; the
11//!   user confirmed "Take over". Finds that instance (lease holder, or the
12//!   compat probe for one that takes no lease), asks it to let go, and waits
13//!   until the agent is free. The pane then runs its turn as usual.
14//! - `POST /agentmux/agent/release {uid}` — on the holder. Gracefully stops
15//!   the CLI process of every pane of this srv that holds `uid`'s lease,
16//!   shows "taken over" in those panes, and keeps this srv from re-claiming
17//!   the agent on its own for a short hold.
18//!
19//! Never jekt-drivable (spec I5, D7): a jekt body cannot reach either route.
20//! The requester side is reached from the pane's own Take over action after
21//! a confirmation; the holder side authenticates with the holder's own full
22//! auth key, which the requester reads from the host-global shared registry
23//! — the same trust cross-channel jekt forwarding already relies on.
24
25use axum::extract::State;
26use axum::http::StatusCode;
27use axum::response::{IntoResponse, Response};
28use axum::Json;
29use serde::Deserialize;
30use serde_json::json;
31
32use super::AppState;
33use crate::backend::agent_admission;
34use crate::backend::blockcontroller;
35use crate::backend::obj::{self, Block};
36
37/// How long the holder lets a turn in flight finish before killing it.
38const RELEASE_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
39
40/// How long the requester waits for the agent to become free.
41const TAKEOVER_WAIT: std::time::Duration = std::time::Duration::from_secs(25);
42
43#[derive(Debug, Deserialize)]
44pub(super) struct TakeoverRequest {
45    block_id: String,
46}
47
48pub(super) async fn handle_agent_takeover(
49    State(state): State<AppState>,
50    Json(req): Json<TakeoverRequest>,
51) -> Response {
52    let block = {
53        let mstore = state.mstore.clone();
54        let block_id = req.block_id.clone();
55        tokio::task::spawn_blocking(move || mstore.get::<Block>(&block_id)).await
56    };
57    let Ok(Ok(Some(block))) = block else {
58        return (StatusCode::NOT_FOUND, Json(json!({ "error": "block not found" }))).into_response();
59    };
60    let uid = obj::meta_get_string(&block.meta, "agentId", "");
61    if uid.is_empty() {
62        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "this pane has no agent" }))).into_response();
63    }
64    let name = obj::meta_get_string(&block.meta, "agentName", "");
65
66    // The user is deliberately taking the agent (back) here.
67    agent_admission::clear_handover_hold(&uid);
68    let store = agent_admission::lease_store_for(state.mstore.shared_agent_registry());
69    let holder = match agent_admission::locate_holder(store.clone(), &uid, &state.boot_id).await {
70        Some(holder) => holder,
71        // No local lease or running instance: the relay may still name a
72        // holder (its WAN lease outlives a closed pane on an older build).
73        None => match wan_takeover_target(&name) {
74            // Nobody else runs it (any more): nothing to take over.
75            WanTarget::NoHolder => {
76                return (StatusCode::OK, Json(json!({ "ok": true, "released": false }))).into_response();
77            }
78            WanTarget::OtherComputer(where_) => {
79                return (StatusCode::CONFLICT, Json(json!({ "error": other_computer_error(&name, &where_) })))
80                    .into_response();
81            }
82            WanTarget::ThisComputer(channel) => match agent_admission::endpoint_for_channel(&channel).await {
83                Some(holder) => holder,
84                None => {
85                    let agent = if name.is_empty() { "this agent" } else { name.as_str() };
86                    let error = format!(
87                        "AgentMux cloud says the instance on this computer in channel {channel} holds {agent}, \
88                         but that instance isn't running. Its hold ends within a minute; try again then."
89                    );
90                    return (StatusCode::CONFLICT, Json(json!({ "error": error }))).into_response();
91                }
92            },
93        },
94    };
95    tracing::info!(
96        uid = %uid,
97        block_id = %req.block_id,
98        holder_channel = %holder.channel,
99        "agent_admission.takeover: requesting release from the holder"
100    );
101    if let Err(e) = agent_admission::request_release(&holder, &uid, &name).await {
102        return (StatusCode::CONFLICT, Json(json!({ "error": e }))).into_response();
103    }
104    if let Err(e) = agent_admission::wait_until_free(store, &uid, &state.boot_id, TAKEOVER_WAIT).await {
105        return (StatusCode::GATEWAY_TIMEOUT, Json(json!({ "error": e }))).into_response();
106    }
107    // The holder let go: forget the cached relay refusal, or the pane's
108    // retry is refused from it for up to 90 s (Codex P1 on #3899).
109    crate::muxbus::wan_lease::forget_elsewhere(&name).await;
110    // ...and take the relay lease now. A holder older than #3899 answers the
111    // release but keeps renewing the lease for as long as it runs, so the new
112    // pane would be fenced seconds after starting (narko 2026-09-27; see
113    // docs/investigations/INVESTIGATION_TAKE_OVER_OLD_HOLDER_RELAY_LEASE_2026_09_27.md).
114    // Unknown (no MuxBus session, an unreachable relay) fails open as before.
115    match crate::muxbus::cloud_subscriber::take_over_lease_now(&state.id_store, &state.http_client, &name).await {
116        crate::muxbus::wan_lease::Outcome::HeldElsewhere(where_) => {
117            let error = if crate::muxbus::wan_lease::holder_on_this_computer(&where_) {
118                kept_relay_hold_error(&name, &where_)
119            } else {
120                other_computer_error(&name, &where_)
121            };
122            tracing::warn!(uid = %uid, holder = %where_, "agent_admission.takeover: the relay still names another holder");
123            return (StatusCode::CONFLICT, Json(json!({ "error": error }))).into_response();
124        }
125        crate::muxbus::wan_lease::Outcome::Held => {
126            tracing::info!(uid = %uid, "agent_admission.takeover: relay lease is ours");
127        }
128        crate::muxbus::wan_lease::Outcome::Unknown => {}
129    }
130    (
131        StatusCode::OK,
132        Json(json!({ "ok": true, "released": true, "from_channel": holder.channel })),
133    )
134        .into_response()
135}
136
137#[derive(Debug, Deserialize)]
138pub(super) struct HoldingQuery {
139    uid: String,
140}
141
142/// `GET /agentmux/agent/holding?uid=…` — a LAN peer asking whether this host
143/// runs `uid` (spec §4.4, Phase 4). LAN-key readable, like the agent-name
144/// list: it discloses only whether an agent UID is live here, since when,
145/// and in which channel/version.
146pub(super) async fn handle_agent_holding(
147    State(state): State<AppState>,
148    axum::extract::Query(q): axum::extract::Query<HoldingQuery>,
149) -> Response {
150    let uid = q.uid.trim().to_string();
151    if uid.is_empty() {
152        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "uid required" }))).into_response();
153    }
154    let store = agent_admission::lease_store_for(state.mstore.shared_agent_registry());
155    let holding = tokio::task::spawn_blocking(move || agent_admission::local_holding(store.as_ref(), &uid))
156        .await
157        .unwrap_or_default();
158    (StatusCode::OK, Json(json!(holding))).into_response()
159}
160
161#[derive(Debug, Deserialize)]
162pub(super) struct ReleaseRequest {
163    uid: String,
164    /// The agent's name, from requesters that send it: lets this srv drop a
165    /// relay subscription (and WAN lease) that outlived the agent's panes.
166    #[serde(default)]
167    agent: String,
168    #[serde(default)]
169    requested_by_channel: String,
170    #[serde(default)]
171    requested_by_version: String,
172}
173
174pub(super) async fn handle_agent_release(State(state): State<AppState>, Json(req): Json<ReleaseRequest>) -> Response {
175    let uid = req.uid.trim().to_string();
176    if uid.is_empty() {
177        return (StatusCode::BAD_REQUEST, Json(json!({ "error": "uid required" }))).into_response();
178    }
179    let blocks = {
180        let mstore = state.mstore.clone();
181        let uid = uid.clone();
182        tokio::task::spawn_blocking(move || {
183            mstore
184                .get_all::<Block>()
185                .unwrap_or_default()
186                .into_iter()
187                .filter(|b| obj::meta_get_string(&b.meta, "agentId", "") == uid)
188                .collect::<Vec<_>>()
189        })
190        .await
191        .unwrap_or_default()
192    };
193    let to = describe_requester(&req.requested_by_channel, &req.requested_by_version);
194    let deadline = std::time::Instant::now() + RELEASE_GRACE;
195    let mut released = 0usize;
196    for block in blocks {
197        let Some(ctrl) = blockcontroller::get_controller(&block.oid) else { continue };
198        let Some(p) = ctrl
199            .as_any()
200            .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
201        else {
202            continue;
203        };
204        if !p.release_to_other_instance(deadline) {
205            continue;
206        }
207        released += 1;
208        let name = obj::meta_get_string(&block.meta, "agentName", "");
209        let who = if name.is_empty() { "This agent" } else { name.as_str() };
210        let reason = format!(
211            "{who} was taken over by another AgentMux instance on this host{}. \
212             Use Take over to bring it back here.",
213            if to.is_empty() { String::new() } else { format!(" ({to})") }
214        );
215        surface_failure(&state, &block.oid, &reason);
216    }
217    // The relay may still see this srv as the holder — even with no pane
218    // running it: a subscription that outlived its panes (charlie,
219    // 2026-09-26) renews the WAN lease every tick. Let go of it now, and
220    // before answering, so the requester's retry can claim the lease.
221    //
222    // Also after stopping live panes (Codex P1 on #3903): their exit hands
223    // the WAN release to a background task, so the requester's retry could
224    // still meet this srv's lease and be fenced. The handover hold keeps
225    // this srv from re-claiming the agent, so letting go here is safe.
226    let agent = req.agent.trim();
227    let was_live = released > 0;
228    // The hold goes up before the release is awaited (Codex P1 on #3908):
229    // otherwise a local turn in that window re-registers the agent, re-adds
230    // its subscription, and this srv keeps the lease it claims to hand over.
231    let hand_over = was_live || !agent.is_empty();
232    if hand_over {
233        agent_admission::hold_after_handover(&uid, &to);
234    }
235    if !agent.is_empty() {
236        use crate::muxbus::cloud_subscriber::RelayRelease;
237        match crate::muxbus::cloud_subscriber::release_agent_now(&state.id_store, agent).await {
238            RelayRelease::Released => {
239                tracing::info!(agent = %agent, was_live, "agent_admission.takeover: released the relay subscription and lease");
240                if !was_live {
241                    released += 1;
242                }
243            }
244            // Re-added despite the hold (a turn already past admission): the
245            // relay lease stays here, so the requester's retry would be fenced.
246            RelayRelease::Kept => {
247                tracing::warn!(agent = %agent, "agent_admission.takeover: the agent was re-added while releasing — relay lease kept");
248            }
249            // No relay subscription (or no relay at all): nothing to release.
250            RelayRelease::NotSubscribed => {}
251        }
252    }
253    if released == 0 && hand_over {
254        // Nothing was handed over after all: don't block this srv's own use.
255        agent_admission::clear_handover_hold(&uid);
256    }
257    tracing::info!(uid = %uid, released, to = %to, "agent_admission.takeover: release handled");
258    (StatusCode::OK, Json(json!({ "ok": true, "released": released }))).into_response()
259}
260
261fn describe_requester(channel: &str, version: &str) -> String {
262    match (channel.is_empty(), version.is_empty()) {
263        (false, false) => format!("channel {channel}, v{version}"),
264        (false, true) => format!("channel {channel}"),
265        (true, false) => format!("v{version}"),
266        (true, true) => String::new(),
267    }
268}
269
270/// Show `message` in the pane's recovery row — the same persisted meta and
271/// live event a spawn refusal uses.
272fn surface_failure(state: &AppState, block_id: &str, message: &str) {
273    let failure = crate::agents::failure::classify(None, None, message, None);
274    blockcontroller::core::persist_last_failure(
275        block_id,
276        Some(&failure),
277        &Some(state.mstore.clone()),
278        &Some(state.event_bus.clone()),
279    );
280    state.broker.publish_event::<crate::backend::mps_events::AgentFailed>(&failure, vec![format!("block:{block_id}")], 1);
281}
282
283/// Who holds `agent` per the relay, when no local lease or running
284/// instance does. Charlie 2026-09-26: a same-computer instance (another
285/// channel) held only the relay lease, and Take over looked nowhere else,
286/// answered `released: false`, and the pane was refused again.
287#[derive(Debug, PartialEq, Eq)]
288enum WanTarget {
289    NoHolder,
290    /// Another instance on this computer, by channel.
291    ThisComputer(String),
292    /// Another computer, as described by the relay's holder record.
293    OtherComputer(String),
294}
295
296fn wan_takeover_target(agent: &str) -> WanTarget {
297    if let Some(channel) = crate::muxbus::wan_lease::held_on_this_computer_by(agent) {
298        return WanTarget::ThisComputer(channel);
299    }
300    match crate::muxbus::wan_lease::held_on_another_computer(agent) {
301        Some(where_) => WanTarget::OtherComputer(where_),
302        None => WanTarget::NoHolder,
303    }
304}
305
306/// Take over reaches instances on this computer only.
307/// The instance on this computer let go of the pane but the relay still
308/// names it, and the relay wouldn't move the lease (a relay without
309/// `/agents/lease/take`, or a lease claimed for another account).
310fn kept_relay_hold_error(agent: &str, where_: &str) -> String {
311    let agent = if agent.is_empty() { "this agent" } else { agent };
312    format!(
313        "AgentMux cloud still says {agent} is held by the AgentMux instance on {where_}. \
314         That instance is probably an older version that can't hand it over. \
315         Quit it completely (from the tray, not just its window); its hold ends \
316         within a minute, then try again."
317    )
318}
319
320fn other_computer_error(agent: &str, where_: &str) -> String {
321    let agent = if agent.is_empty() { "this agent" } else { agent };
322    format!(
323        "{agent} is running on another computer ({where_}), according to AgentMux cloud. \
324         Take over only works between AgentMux instances on this computer. \
325         Close {agent} there, then try again."
326    )
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn the_requester_is_described_by_what_it_sent() {
335        assert_eq!(describe_requester("dev-x", "0.58.0"), "channel dev-x, v0.58.0");
336        assert_eq!(describe_requester("", "0.58.0"), "v0.58.0");
337        assert_eq!(describe_requester("", ""), "");
338    }
339
340    /// The holder pane's message is classified as the takeover case, so its
341    /// row offers Take over (to bring the agent back), not Retry.
342    #[test]
343    fn the_holder_side_message_classifies_as_taken_over() {
344        let reason = "Agent3 was taken over by another AgentMux instance on this host (channel dev-x, v0.58.0). \
345                      Use Take over to bring it back here.";
346        let f = crate::agents::failure::classify(None, None, reason, None);
347        assert_eq!(f.code, crate::agents::failure::FailureClass::LiveElsewhere);
348        assert_eq!(f.title, "Taken over by another AgentMux instance");
349    }
350
351    // Charlie 2026-09-26: the relay said 0.57.6 on the same computer held
352    // Opaz, but Take over only looked at the local lease file and running
353    // instances, found nobody, answered `released: false` and did nothing.
354    #[test]
355    fn take_over_follows_a_relay_holder_on_this_computer() {
356        let here = crate::backend::reactive::registry::local_host_label();
357        let agent = format!("agent-{}", uuid::Uuid::new_v4());
358        crate::muxbus::wan_lease::note_not_holder(
359            &agent,
360            &serde_json::json!({ "held_by": { "host": here, "channel": "local-main-x", "version": "0.57.6" } }),
361        );
362        assert_eq!(wan_takeover_target(&agent), WanTarget::ThisComputer("local-main-x".into()));
363    }
364
365    #[test]
366    fn take_over_says_why_it_cannot_reach_another_computer() {
367        let agent = format!("agent-{}", uuid::Uuid::new_v4());
368        crate::muxbus::wan_lease::note_not_holder(&agent, &serde_json::json!({ "held_by": { "host": "desk", "channel": "stable" } }));
369        let WanTarget::OtherComputer(where_) = wan_takeover_target(&agent) else { panic!("expected another computer") };
370        let m = other_computer_error("Opaz", &where_);
371        assert!(m.contains("another computer (computer desk, channel stable)"), "{m}");
372        assert!(m.contains("Close Opaz there"), "{m}");
373        assert_eq!(wan_takeover_target("never-seen"), WanTarget::NoHolder);
374    }
375}