1use 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
37const RELEASE_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
39
40const 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 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 None => match wan_takeover_target(&name) {
74 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 crate::muxbus::wan_lease::forget_elsewhere(&name).await;
110 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
142pub(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 #[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 let agent = req.agent.trim();
227 let was_live = released > 0;
228 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 RelayRelease::Kept => {
247 tracing::warn!(agent = %agent, "agent_admission.takeover: the agent was re-added while releasing — relay lease kept");
248 }
249 RelayRelease::NotSubscribed => {}
251 }
252 }
253 if released == 0 && hand_over {
254 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
270fn 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#[derive(Debug, PartialEq, Eq)]
288enum WanTarget {
289 NoHolder,
290 ThisComputer(String),
292 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
306fn 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 #[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 #[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}