Skip to main content

agentmux_srv/sagas/
self_quit.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Self-quit — end ONE agent gracefully and close its own tab
5// (docs/specs/SPEC_AGENT_SELF_QUIT_2026_09_24.md §3, §4.3; Phase 1).
6//
7// Order (§4.3):
8// 1. Resolve who the agent is while it is still registered — shutdown
9//    unregisters the block, and a later lookup falls back to the bare id.
10// 2–4. `delete_block::run_with_policy(Policy::quit())`: the one teardown
11//    (`sagas::agent_teardown`, SPEC_AGENT_TEARDOWN_SINGLE_PATH_2026_10_01.md)
12//    releases its work claims, stops the `Shell()` children it spawned and
13//    shuts it down gracefully; then a stack-aware layout prune that keeps
14//    sibling tabs, and the frontend update for an already-loaded tree (§12.4).
15// 5. Audit, success or failure.
16//
17// Crons that target the agent are kept (decided Q2: a cron is a deliberate
18// schedule that resumes when the agent is reopened) and reported, so the
19// user can pause or delete them if they meant "stop everything".
20
21use serde::Serialize;
22
23use crate::server::AppState;
24
25/// Who asked.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum QuitOrigin {
28    /// The user typed `/quit` or `/exit` in the agent's composer.
29    UserSlash,
30    /// The agent's own `QuitSelf` tool, in a turn the user started (§6).
31    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/// Why `QuitSelf` doesn't proceed at once (§6.3). Until Phase 2b's override
44/// window exists, every one of these is a refusal (spec §9: the non-user path
45/// ships with 2b).
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum GateRefusal {
48    /// The controller can't say what started the turn (not a Claude
49    /// persistent agent, or a start nothing labelled).
50    Unknown,
51    /// The turn was started by something other than the user.
52    NotUserTurn,
53    /// The user started it, but other input got in since.
54    Tainted,
55    /// The quoted instruction isn't in the message that started the turn.
56    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
74/// §6.3: the quote must appear in the user's message, whitespace- and
75/// case-insensitively. An empty quote never matches.
76pub 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
81/// The §6.3 gate: may `QuitSelf` proceed without asking the user?
82pub 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
100/// Blocks with a `QuitSelf` waiting for their turn to end (§4.2's
101/// `mark_quitting`): a second call answers `already_quitting`.
102static QUITTING: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
103    std::sync::LazyLock::new(Default::default);
104
105/// How long a scheduled quit waits for the turn to end (§4.2); after that
106/// the graceful shutdown interrupts it.
107pub 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
114/// §4.2: the tool call is part of the turn that asked for it, so quit when
115/// that turn ends (or after `SELF_QUIT_TURN_GRACE`), not now. Returns false
116/// when a quit is already scheduled or under way.
117pub 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    /// `"quit"`, or `"already_quitting"` for a block whose close is already
145    /// under way (a second `/quit`, or `/quit` racing the pane ×).
146    pub status: &'static str,
147    pub agent: String,
148    pub released_claims: usize,
149    pub stopped_shells: usize,
150    /// Names of cron jobs still targeting the agent (kept, §4.3).
151    pub crons_targeting: Vec<String>,
152    /// Processes still running after the quit, as `name (pid N)`
153    /// (agent teardown spec §6.2 step 8, §7.3). Empty is the goal.
154    pub survivors: Vec<String>,
155    /// Running Docker Compose containers from the agent's folder, left
156    /// running (`agent_teardown::compose_containers_left`).
157    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
164/// [`run`] with an audit detail (for `QuitSelf`: the reason and the user's
165/// quoted instruction).
166pub 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    // 1. Identity, before anything unregisters the block (the audit names
179    //    the agent even if the close fails).
180    let agent = crate::server::agent_resources::AgentIdentity::of(state, block_id).label(block_id);
181    // 2–4. One teardown with the quit policy: releases claims, stops its
182    //      shells and shuts it down, then the block is closed.
183    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    // The real counts even when the close failed after the teardown ran.
191    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    // 5. Audit.
202    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    /// Spec §3.2 + §12.4: quitting one tab of a two-tab pane closes only that
326    /// tab, keeps the pane and the sibling, and queues the frontend `delete`
327    /// so an already-loaded frontend drops the tab instead of resurrecting it.
328    #[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}