1use serde::Serialize;
22
23use crate::server::AppState;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum QuitOrigin {
28 UserSlash,
30 AgentTool,
32}
33
34impl QuitOrigin {
35 pub fn as_str(self) -> &'static str {
36 match self {
37 QuitOrigin::UserSlash => "user_slash",
38 QuitOrigin::AgentTool => "agent_tool",
39 }
40 }
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum GateRefusal {
48 Unknown,
51 NotUserTurn,
53 Tainted,
55 InstructionMismatch,
57}
58
59impl GateRefusal {
60 pub fn as_str(self) -> &'static str {
61 match self {
62 GateRefusal::Unknown => "turn_origin_unknown",
63 GateRefusal::NotUserTurn => "not_user_turn",
64 GateRefusal::Tainted => "turn_tainted",
65 GateRefusal::InstructionMismatch => "user_instruction_mismatch",
66 }
67 }
68}
69
70fn normalized(s: &str) -> String {
71 s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
72}
73
74pub fn quote_matches(user_text: &str, quote: &str) -> bool {
77 let q = normalized(quote);
78 !q.is_empty() && normalized(user_text).contains(&q)
79}
80
81pub fn gate(
83 provenance: Option<&crate::backend::blockcontroller::health::TurnProvenance>,
84 user_instruction: &str,
85) -> Result<(), GateRefusal> {
86 use crate::backend::blockcontroller::health::TurnOrigin;
87 let p = provenance.ok_or(GateRefusal::Unknown)?;
88 if p.origin != TurnOrigin::User {
89 return Err(GateRefusal::NotUserTurn);
90 }
91 if p.tainted {
92 return Err(GateRefusal::Tainted);
93 }
94 match p.user_text.as_deref() {
95 Some(text) if quote_matches(text, user_instruction) => Ok(()),
96 _ => Err(GateRefusal::InstructionMismatch),
97 }
98}
99
100static QUITTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
103 std::sync::LazyLock::new(Default::default);
104
105pub const SELF_QUIT_TURN_GRACE: std::time::Duration = std::time::Duration::from_secs(30);
108
109pub fn is_quitting(block_id: &str) -> bool {
110 QUITTING.lock().unwrap_or_else(|e| e.into_inner()).contains(block_id)
111 || crate::backend::blockcontroller::is_closing(block_id)
112}
113
114pub fn schedule_after_turn(state: &AppState, block_id: &str, detail: String) -> bool {
118 {
119 let mut q = QUITTING.lock().unwrap_or_else(|e| e.into_inner());
120 if q.contains(block_id) || crate::backend::blockcontroller::is_closing(block_id) {
121 return false;
122 }
123 q.insert(block_id.to_string());
124 }
125 let (state, block_id) = (state.clone(), block_id.to_string());
126 tokio::spawn(async move {
127 let deadline = tokio::time::Instant::now() + SELF_QUIT_TURN_GRACE;
128 while tokio::time::Instant::now() < deadline
129 && crate::backend::blockcontroller::get_controller(&block_id)
130 .is_some_and(|c| c.get_runtime_status().turn_active)
131 {
132 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
133 }
134 if let Err(e) = run_with(&state, &block_id, QuitOrigin::AgentTool, Some(&detail)).await {
135 tracing::warn!(block_id = %block_id, error = %e, "self-quit (agent tool) failed");
136 }
137 QUITTING.lock().unwrap_or_else(|e| e.into_inner()).remove(&block_id);
138 });
139 true
140}
141
142#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
143pub struct QuitSummary {
144 pub status: &'static str,
147 pub agent: String,
148 pub released_claims: usize,
149 pub stopped_shells: usize,
150 pub crons_targeting: Vec<String>,
152 pub survivors: Vec<String>,
155 pub containers_left: Vec<String>,
158}
159
160pub async fn run(state: &AppState, block_id: &str, origin: QuitOrigin) -> Result<QuitSummary, String> {
161 run_with(state, block_id, origin, None).await
162}
163
164pub async fn run_with(state: &AppState, block_id: &str, origin: QuitOrigin, detail: Option<&str>) -> Result<QuitSummary, String> {
167 if crate::backend::blockcontroller::is_closing(block_id) {
168 return Ok(QuitSummary { status: "already_quitting", ..Default::default() });
169 }
170 let tab_id = {
171 let s = state.srv_state.lock().await;
172 match s.blocks.get(block_id) {
173 Some(rec) => rec.tab_id.clone(),
174 None => return Err(format!("QuitAgent: block not found: {block_id}")),
175 }
176 };
177
178 let agent = crate::server::agent_resources::AgentIdentity::of(state, block_id).label(block_id);
181 let (result, report) = crate::sagas::delete_block::run_with_policy(
184 state,
185 tab_id,
186 block_id.to_string(),
187 crate::sagas::agent_teardown::Policy::quit(),
188 )
189 .await;
190 let report = report.unwrap_or_default();
192 let (released_claims, stopped_shells, crons) =
193 (report.released_claims, report.stopped_shells, report.crons_targeting.clone());
194 let containers_left = report.containers_left.clone();
195 let survivors: Vec<String> = report
196 .survivors
197 .iter()
198 .map(|p| format!("{} (pid {})", crate::sagas::close_pane::short_command(&p.command), p.pid))
199 .collect();
200
201 let request_id = uuid::Uuid::new_v4().to_string();
203 state.reactive_handler.log_fleet_action_audit(
204 None,
205 &agent,
206 block_id,
207 "agent.quit",
208 result.is_ok(),
209 result.as_ref().err().map(String::as_str),
210 &request_id,
211 Some(&match detail {
212 Some(d) => format!("{}: {d}", origin.as_str()),
213 None => origin.as_str().to_string(),
214 }),
215 );
216 tracing::info!(
217 block_id,
218 agent = %agent,
219 origin = origin.as_str(),
220 released_claims,
221 stopped_shells,
222 crons_targeting = crons.len(),
223 survivors = survivors.len(),
224 ok = result.is_ok(),
225 "self-quit"
226 );
227 result?;
228 Ok(QuitSummary { status: "quit", agent, released_claims, stopped_shells, crons_targeting: crons, survivors, containers_left })
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::server::agent_resources::{cron_targets, holds_claim};
235 use crate::server::tests::test_state;
236 use agentmux_common::ipc::{Command, Event};
237
238 #[test]
239 fn claims_are_matched_by_uid_when_both_sides_have_one_else_by_name() {
240 assert!(holds_claim("Lark", "uid-lark", "Lark", Some("uid-lark")));
241 assert!(holds_claim("Lark", "", "Lark", Some("uid-lark")), "claimed before UIDs existed: by name");
242 assert!(!holds_claim("Lark", "uid-other", "Lark", Some("uid-lark")), "a same-named other agent");
243 assert!(!holds_claim("Korp", "uid-korp", "Lark", Some("uid-lark")));
244 assert!(holds_claim("Lark", "uid-other", "Lark", None), "no UID of our own: by name");
245 assert!(!holds_claim("", "", "", None), "an unknown agent releases nothing");
246 }
247
248 #[test]
249 fn crons_targeting_the_agent_are_matched_by_uid_or_name() {
250 assert!(cron_targets("Lark", "uid-lark", "Lark", Some("uid-lark")));
251 assert!(cron_targets("lark", "", "Lark", Some("uid-lark")), "legacy job, name only, case-insensitive");
252 assert!(!cron_targets("Lark", "uid-other", "Lark", Some("uid-lark")));
253 assert!(!cron_targets("Korp", "uid-korp", "Lark", Some("uid-lark")));
254 }
255
256 async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
257 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
258 for ev in &events {
259 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
260 }
261 events
262 }
263
264 async fn seed_two_tab_pane(state: &AppState) -> (String, String, String) {
265 let ws = dispatch_apply(state, Command::CreateWorkspace { name: "w".into() })
266 .await
267 .iter()
268 .find_map(|e| match e {
269 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
270 _ => None,
271 })
272 .unwrap();
273 let tab_id = dispatch_apply(state, Command::CreateTab { workspace_id: ws, name: "t".into() })
274 .await
275 .iter()
276 .find_map(|e| match e {
277 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
278 _ => None,
279 })
280 .unwrap();
281 let mut blocks = Vec::new();
282 for _ in 0..2 {
283 blocks.push(
284 dispatch_apply(state, Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null })
285 .await
286 .iter()
287 .find_map(|e| match e {
288 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
289 _ => None,
290 })
291 .unwrap(),
292 );
293 }
294 let (a, b) = (blocks[0].clone(), blocks[1].clone());
295 let leaf = agentmux_common::LayoutNode {
296 id: "leaf".into(),
297 data: Some(agentmux_common::LayoutNodeData {
298 block_id: a.clone(),
299 block_stack: vec![a.clone(), b.clone()],
300 active_block_id: a.clone(),
301 ..Default::default()
302 }),
303 ..Default::default()
304 };
305 dispatch_apply(
306 state,
307 Command::LayoutSetTree { tab_id: tab_id.clone(), new_tree: Some(leaf), correlation_id: String::new(), slices: None },
308 )
309 .await;
310 (tab_id, a, b)
311 }
312
313 fn queued_deletes(state: &AppState, tab_id: &str) -> Vec<String> {
314 let tab = state.mstore.must_get::<crate::backend::obj::Tab>(tab_id).unwrap();
315 let layout = state.mstore.must_get::<crate::backend::obj::LayoutState>(&tab.layoutstate).unwrap();
316 layout
317 .pendingbackendactions
318 .unwrap_or_default()
319 .into_iter()
320 .filter(|a| a.actiontype == "delete")
321 .map(|a| a.blockid)
322 .collect()
323 }
324
325 #[tokio::test]
329 async fn quitting_one_tab_keeps_the_pane_and_tells_the_frontend() {
330 let state = test_state();
331 let (tab_id, a, b) = seed_two_tab_pane(&state).await;
332
333 let summary = run(&state, &a, QuitOrigin::UserSlash).await.expect("quit");
334 assert_eq!(summary.status, "quit");
335 assert_eq!((summary.released_claims, summary.stopped_shells), (0, 0));
336
337 let s = state.srv_state.lock().await;
338 assert!(!s.blocks.contains_key(&a), "the quitting tab is gone");
339 assert!(s.blocks.contains_key(&b), "the sibling tab keeps running");
340 assert!(s.tabs[&tab_id].rootnode.is_some(), "the pane survives");
341 drop(s);
342 assert_eq!(queued_deletes(&state, &tab_id), vec![a.clone()], "frontend told to drop the tab");
343
344 assert!(run(&state, &a, QuitOrigin::UserSlash).await.is_err(), "already gone: not found");
345 }
346
347 #[test]
348 fn the_quote_must_be_in_the_users_message_give_or_take_spacing_and_case() {
349 assert!(quote_matches("Finish the PR, then quit.", "then quit"));
350 assert!(quote_matches("finish the PR,\n then QUIT", "then quit"));
351 assert!(!quote_matches("finish the PR", "then quit"), "not what the user said");
352 assert!(!quote_matches("quit", ""), "an empty quote proves nothing");
353 }
354
355 #[test]
356 fn the_gate_passes_only_an_untainted_user_turn_with_a_matching_quote() {
357 use crate::backend::blockcontroller::health::{TurnOrigin, TurnProvenance};
358 let p = |origin, tainted, text: Option<&str>| TurnProvenance { origin, tainted, user_text: text.map(Into::into) };
359 let ok = p(TurnOrigin::User, false, Some("finish the PR then quit"));
360 assert_eq!(gate(Some(&ok), "then quit"), Ok(()));
361 assert_eq!(gate(None, "then quit"), Err(GateRefusal::Unknown));
362 assert_eq!(gate(Some(&p(TurnOrigin::Automated, false, None)), "quit"), Err(GateRefusal::NotUserTurn));
363 assert_eq!(gate(Some(&p(TurnOrigin::System, false, None)), "quit"), Err(GateRefusal::NotUserTurn));
364 assert_eq!(gate(Some(&p(TurnOrigin::User, true, Some("quit"))), "quit"), Err(GateRefusal::Tainted));
365 assert_eq!(gate(Some(&ok), "you may now exit"), Err(GateRefusal::InstructionMismatch));
366 }
367
368 #[tokio::test]
369 async fn a_block_already_closing_reports_already_quitting_and_touches_nothing() {
370 let state = test_state();
371 let (_tab, a, _b) = seed_two_tab_pane(&state).await;
372 crate::backend::blockcontroller::mark_closing(&a);
373 let summary = run(&state, &a, QuitOrigin::UserSlash).await.unwrap();
374 crate::backend::blockcontroller::unmark_closing(&a);
375 assert_eq!(summary.status, "already_quitting");
376 assert!(state.srv_state.lock().await.blocks.contains_key(&a), "left alone");
377 }
378}