Skip to main content

agentmux_srv/sagas/
agent_teardown.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Agent teardown — the ONE path that stops what an agent owns
5// (docs/specs/SPEC_AGENT_TEARDOWN_SINGLE_PATH_2026_10_01.md).
6//
7// Every way of ending an agent calls this with a `Policy`; nothing else in
8// srv stops an agent's processes (the structural test below pins it):
9//
10// | Consumer                                              | Policy            |
11// |-------------------------------------------------------|-------------------|
12// | pane × / tab × / window-tab × / window ×, ClosePane    | `Policy::close()` |
13// |   (`close_pane`, `delete_block`, `delete_tab`,        |                   |
14// |   `delete_workspace`; the orphan reaper via close_pane)|                   |
15// | `/quit`, `QuitSelf`, no-arg `ClosePane` (`self_quit`)  | `Policy::quit()`  |
16// | Stop, `agent.stop`, `FleetBulkStop` Stop, the watchdog | `Policy::stop(..)`|
17// | Controller replace (`resync_controller`)              | `Policy::replace()` |
18// | App exit (`main.rs`)                                  | `Policy::app_exit()` |
19// | A drawer closed, a `/btw` throwaway, a spawn rollback, | `discard`         |
20// |   the backstop after a close                          |                   |
21//
22// `Shell()` and `!cmd` processes join the agent's tracker when they spawn
23// (`track_adopted`), and a graceful teardown closes the agent's PtyShell
24// drawers first, so all of them end with it.
25//
26// Phase 1 moved today's behaviour here unchanged, encoded as policies: the
27// close and quit paths are the former `close_pane::shutdown_one` and
28// `self_quit`'s claim/shell steps, verbatim; Stop is `ctrl.stop()`. Phase 2 adds controller
29// replace (`stop_for_replace`, unchanged) and app exit, which now closes every
30// agent gracefully instead of leaving them to the launcher's backstop. Later
31// phases change what a policy covers (spec §10) without touching the consumers.
32
33use serde::Serialize;
34
35use crate::backend::blockcontroller;
36use crate::server::agent_resources::{self, AgentIdentity, AgentResources};
37use crate::server::AppState;
38
39use super::close_pane::{exit_line, process_extra, process_line, publish_shutdown, save_final_state, short_command, ShutdownExtra};
40
41/// How the agent's own process is stopped.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum CliStop {
44    /// Stop routing input to it, end the turn, ask it to exit, force-kill at
45    /// the deadline, then drop the block's process tracker (which takes what
46    /// the agent started) and save its final state (spec §6.2 steps 1, 4–9).
47    Graceful,
48    /// `controller.stop()` and nothing else: the controller stays registered
49    /// and the tracker is kept, so what the agent started keeps running
50    /// (today's Stop; spec §5).
51    StopOnly { graceful: bool },
52    /// The controller is being replaced by a new one for the same block
53    /// (session restart): only its own CLI process dies; the tracker and
54    /// every declared-background task survive and are adopted by the new
55    /// controller (`SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md`).
56    Replace,
57}
58
59/// What one teardown covers (spec §5). A consumer picks a constructor; it
60/// never adds its own kill code.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct Policy {
63    pub cli: CliStop,
64    /// Release the agent's work-queue claims (R7) and report the crons that
65    /// target it (R8, kept).
66    pub release_claims: bool,
67    /// Stop the srv-spawned `Shell()` sessions it started (R3).
68    pub stop_shell_sessions: bool,
69    /// Stop its declared `run_in_background` tasks (R2) explicitly. A
70    /// graceful close takes them anyway with the tracker; this is for Stop,
71    /// which keeps the tracker (spec §5, §7.4).
72    pub stop_background: bool,
73    /// Stop a container agent's container (R6), which also drops its
74    /// dev-proxy routes. Stopped, not removed: the next launch restarts it
75    /// (`ContainerManager::ensure_running`), and its volume keeps its state.
76    pub stop_container: bool,
77}
78
79impl Policy {
80    /// A pane, tab, window-tab or window closing: the agent and everything
81    /// it runs, `Shell()` sessions included; claims stay until their lease
82    /// expires, as before (spec §5).
83    pub const fn close() -> Self {
84        Self { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true }
85    }
86    /// `/quit`, `QuitSelf`, no-argument `ClosePane`: everything (spec §0).
87    pub const fn quit() -> Self {
88        Self { cli: CliStop::Graceful, release_claims: true, stop_shell_sessions: true, stop_background: false, stop_container: true }
89    }
90    /// Stop (`agent.stop`, `FleetBulkStop`): the CLI, and its background
91    /// tasks only if asked; its claims go back to the pool (spec §5). The
92    /// block, its `Shell()` sessions and drawers stay.
93    pub const fn stop(graceful: bool, stop_background: bool) -> Self {
94        Self { cli: CliStop::StopOnly { graceful }, release_claims: true, stop_shell_sessions: false, stop_background, stop_container: false }
95    }
96    /// The watchdog's max-runtime / idle stop: the CLI only, as it always
97    /// has been. Its claims stay, since it may be restarted on the next
98    /// message.
99    pub const fn stop_cli_only(graceful: bool) -> Self {
100        Self { cli: CliStop::StopOnly { graceful }, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false }
101    }
102    /// Restart / controller replace: the CLI only; background tasks survive.
103    pub const fn replace() -> Self {
104        Self { cli: CliStop::Replace, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false }
105    }
106    /// App exit: every agent closes gracefully and its `Shell()` sessions
107    /// stop. Claims stay: the agent comes back with the app and its lease
108    /// covers the gap (spec §5).
109    pub const fn app_exit() -> Self {
110        Self { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true }
111    }
112}
113
114/// Setting: does Stop keep the agent's `run_in_background` tasks (dev
115/// servers) running when the caller doesn't say? Per user, default `true`,
116/// today's behaviour (spec §11 O2).
117pub const SETTING_STOP_KEEPS_BACKGROUND: &str = "agent:stopkeepsbackground";
118
119/// App exit's overall cap (spec §11 O3), the leftover-shell sweep included:
120/// every agent closes concurrently under it. The launcher waits longer than
121/// this for srv to exit on every quit (`SRV_EXIT_WAIT`, `quiesce_srv`), so
122/// it force-kills srv only if the teardown overran.
123pub const APP_EXIT_CAP: std::time::Duration = agentmux_common::process::SRV_APP_EXIT_CAP;
124
125/// The grace the leftover-shell sweep gives its kill tasks. [reagent #1422 P2]
126const SHELL_SWEEP_GRACE: std::time::Duration = std::time::Duration::from_millis(800);
127
128/// What a teardown did, for the consumer's summary and the audit.
129#[derive(Clone, Debug, Default, Serialize)]
130pub struct TeardownReport {
131    pub block_id: String,
132    /// The agent's id, else its block id.
133    pub agent: String,
134    pub released_claims: usize,
135    pub stopped_shells: usize,
136    /// Cron jobs still targeting the agent (kept).
137    pub crons_targeting: Vec<String>,
138    /// Background tasks stopped explicitly (Stop with `stop_background`).
139    pub stopped_background: usize,
140    /// Stop only: the CLI stop failed. Nothing else was touched (claims and
141    /// background tasks stay), and [`stop`] returns this as its error.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub stop_error: Option<String>,
144    /// The container agent's container that was stopped, if any.
145    pub stopped_container: Option<String>,
146    /// Running Docker Compose containers from the agent's working directory,
147    /// left running and reported (`compose_containers_left`).
148    #[serde(skip_serializing_if = "Vec::is_empty")]
149    pub containers_left: Vec<String>,
150    /// Processes from `before` still running after the teardown and one more
151    /// forced kill each (spec §6.2 step 8). Empty is the goal.
152    pub survivors: Vec<agent_resources::ProcessEntry>,
153    /// What it owned when the teardown began.
154    pub before: AgentResources,
155}
156
157/// Every teardown and window close in flight. Each runs as its own task, so
158/// a caller that stops waiting can't cut it short: an HTTP handler whose
159/// client hung up (the host gives `CloseWindow` 2 s) or srv's servers
160/// stopping on quit. [`app_exit`] waits for all of them.
161static CLOSES: std::sync::LazyLock<tokio_util::task::TaskTracker> =
162    std::sync::LazyLock::new(tokio_util::task::TaskTracker::new);
163
164/// Run `fut` to completion as a tracked task, even if the caller is dropped
165/// first; [`app_exit`] waits for it. `None` if it panicked.
166pub(crate) async fn detached<T: Send + 'static>(fut: impl std::future::Future<Output = T> + Send + 'static) -> Option<T> {
167    CLOSES.spawn(fut).await.ok()
168}
169
170/// Tear down every block in `block_ids` concurrently, under ONE deadline:
171/// ten agents take one grace period, not ten. Each runs [`detached`].
172pub async fn run_many(state: &AppState, block_ids: &[String], policy: Policy) -> Vec<TeardownReport> {
173    let deadline = std::time::Instant::now() + blockcontroller::SHUTDOWN_GRACE;
174    let teardowns = block_ids.iter().map(|id| {
175        let (st, id) = (state.clone(), id.clone());
176        async move {
177            let report = detached({
178                let id = id.clone();
179                async move { run_one(&st, &id, policy, deadline).await }
180            })
181            .await;
182            report.unwrap_or_else(|| {
183                tracing::error!(block_id = %id, "agent_teardown: teardown panicked");
184                TeardownReport { agent: id.clone(), block_id: id, ..Default::default() }
185            })
186        }
187    });
188    futures_util::future::join_all(teardowns).await
189}
190
191/// [`run_many`] for one block.
192pub async fn run(state: &AppState, block_id: &str, policy: Policy) -> TeardownReport {
193    run_many(state, std::slice::from_ref(&block_id.to_string()), policy)
194        .await
195        .pop()
196        .unwrap_or_default()
197}
198
199/// [`Policy::stop_cli_only`]'s entry, for callers with no `AppState` (the
200/// watchdog, the pane's `AgentStop` command): `controller.stop()` only.
201/// Errors with `NOT_RUNNING` when the block has no controller.
202pub fn stop_now(block_id: &str, graceful: bool) -> Result<(), String> {
203    debug_assert!(matches!(Policy::stop_cli_only(graceful).cli, CliStop::StopOnly { .. }));
204    let ctrl = blockcontroller::get_controller(block_id)
205        .ok_or_else(|| format!("NOT_RUNNING: no controller for block {block_id}"))?;
206    ctrl.stop(graceful, blockcontroller::STATUS_DONE)
207}
208
209/// Stop (`agent.stop`, `FleetBulkStop`, a deferred `Stop` action):
210/// [`Policy::stop`]. `stop_background` is the caller's answer to "also stop
211/// its background tasks?"; `None` takes the user's setting
212/// ([`SETTING_STOP_KEEPS_BACKGROUND`]). Errors with `NOT_RUNNING` when the
213/// block has no controller, as `agent.stop` always has.
214pub async fn stop(state: &AppState, block_id: &str, graceful: bool, stop_background: Option<bool>) -> Result<TeardownReport, String> {
215    if blockcontroller::get_controller(block_id).is_none() {
216        return Err(format!("NOT_RUNNING: no controller for block {block_id}"));
217    }
218    let stop_background = stop_background.unwrap_or_else(|| !stop_keeps_background(state));
219    let report = run(state, block_id, Policy::stop(graceful, stop_background)).await;
220    match report.stop_error {
221        Some(e) => Err(e),
222        None => Ok(report),
223    }
224}
225
226fn stop_keeps_background(state: &AppState) -> bool {
227    state
228        .config_watcher
229        .get_settings()
230        .extra
231        .get(SETTING_STOP_KEEPS_BACKGROUND)
232        .and_then(|v| v.as_bool())
233        .unwrap_or(true)
234}
235
236/// Drop a block's controller and process tracker at once, with no graceful
237/// phase: for a controller that isn't an agent to wind down (a PtyShell
238/// drawer the user closed, a `/btw` throwaway, a spawn being rolled back) and
239/// as the backstop after a close that already ran the teardown (idempotent;
240/// finds nothing left).
241pub fn discard(block_id: &str) {
242    blockcontroller::delete_controller(block_id);
243}
244
245/// The replace policy's entry, for `resync_controller` (synchronous, below
246/// the saga layer, and already holding the old controller): stop its own CLI
247/// process only (`Policy::replace`).
248pub fn replace_now(ctrl: &dyn blockcontroller::Controller) -> Result<(), String> {
249    debug_assert!(matches!(Policy::replace().cli, CliStop::Replace));
250    ctrl.stop_for_replace(blockcontroller::STATUS_DONE)
251}
252
253/// App exit (`main.rs`, after the servers stop): tear down every live agent
254/// with `Policy::app_exit()`, concurrently, and wait for every close already
255/// in flight, all under [`APP_EXIT_CAP`]. Then stop any `Shell()` session left
256/// over (one whose agent is already gone, or whose teardown hit the cap).
257/// Returns how many controllers it closed.
258pub async fn app_exit(state: &AppState) -> usize {
259    // Durable SSH panes detach rather than end their sessions: they live on
260    // on their hosts for the next start to reattach (durable_ssh.rs).
261    crate::backend::blockcontroller::durable_ssh::note_app_exiting();
262    let all: Vec<String> = blockcontroller::get_all_controllers().into_keys().collect();
263    // A drawer is closed by its parent's teardown (`close_sub_blocks`); listing
264    // it here too would run two teardowns of one block at once.
265    let ids = {
266        let (st, ids) = (state.clone(), all.clone());
267        tokio::task::spawn_blocking(move || without_sub_blocks(&st, ids)).await.unwrap_or(all)
268    };
269    let cap = APP_EXIT_CAP.saturating_sub(SHELL_SWEEP_GRACE);
270    let all_closed = async {
271        run_many(state, &ids, Policy::app_exit()).await;
272        // And every close already in flight: its controller has left the
273        // registry, so the list above doesn't have it (a window closed just
274        // before the quit, whose host stopped waiting for the reply).
275        CLOSES.close();
276        CLOSES.wait().await;
277    };
278    if tokio::time::timeout(cap, all_closed).await.is_err() {
279        tracing::warn!(agents = ids.len(), "app exit: teardown hit the cap; the launcher's backstop takes the rest");
280    }
281    // The kill tasks run taskkill/killpg asynchronously; give them a brief
282    // grace to complete before srv exits. [reagent #1422 P2]
283    let live = state.shell_sessions.stop_all();
284    if live > 0 {
285        tracing::info!(count = live, "app exit: stopping leftover persistent shells");
286        tokio::time::sleep(SHELL_SWEEP_GRACE).await;
287    }
288    ids.len()
289}
290
291/// `ids` minus any that is a sub-block (PtyShell drawer) of another block in
292/// it. Store reads: call off the async workers.
293pub(crate) fn without_sub_blocks(state: &AppState, ids: Vec<String>) -> Vec<String> {
294    let subs: std::collections::HashSet<String> = ids
295        .iter()
296        .filter_map(|id| state.mstore.get::<crate::backend::obj::Block>(id).ok().flatten())
297        .flat_map(|b| b.subblockids.unwrap_or_default())
298        .collect();
299    ids.into_iter().filter(|id| !subs.contains(id)).collect()
300}
301
302async fn run_one(state: &AppState, block_id: &str, policy: Policy, deadline: std::time::Instant) -> TeardownReport {
303    let started = std::time::Instant::now();
304    // Identity and inventory while the block is still registered (step 2).
305    let who = AgentIdentity::of(state, block_id);
306    // Store reads (SQLite) off the async workers: a bulk close runs this
307    // for every block at once.
308    let before = {
309        let (st, id) = (state.clone(), block_id.to_string());
310        tokio::task::spawn_blocking(move || agent_resources::snapshot(&st, &id))
311            .await
312            .unwrap_or_default()
313    };
314    let mut report = TeardownReport {
315        block_id: block_id.to_string(),
316        agent: who.label(block_id),
317        before: before.clone(),
318        ..Default::default()
319    };
320
321    // Stop: the CLI first. If it fails the agent is still running, so its
322    // claims and background tasks must stay too (ReAgent P1 on #4174).
323    if let CliStop::StopOnly { graceful } = policy.cli {
324        if let Err(e) = stop_now(block_id, graceful) {
325            tracing::warn!(block_id = %block_id, error = %e, "agent_teardown: stop failed; nothing else touched");
326            report.stop_error = Some(e);
327            return report;
328        }
329    }
330
331    // Step 3: non-process resources, so nothing new reaches a dying agent.
332    if policy.release_claims {
333        let reason = if matches!(policy.cli, CliStop::StopOnly { .. }) { "agent stopped" } else { "agent quit" };
334        let (released, crons) = release_claims_and_list_crons(state, &who, reason).await;
335        report.released_claims = released;
336        report.crons_targeting = crons;
337    }
338    if policy.stop_shell_sessions {
339        report.stopped_shells = before
340            .shell_sessions
341            .iter()
342            .filter(|s| state.shell_sessions.stop(&s.shell_id))
343            .count();
344    }
345
346    if policy.stop_background && !before.background_tasks.is_empty() {
347        report.stopped_background = stop_background_tasks(&before).await;
348    }
349
350    match policy.cli {
351        CliStop::StopOnly { .. } => {} // done first, above
352        CliStop::Replace => {
353            if let Some(ctrl) = blockcontroller::get_controller(block_id) {
354                if let Err(e) = replace_now(ctrl.as_ref()) {
355                    tracing::debug!(block_id = %block_id, error = %e, "agent_teardown: replace");
356                }
357            }
358        }
359        CliStop::Graceful => {
360            // PtyShell drawers first (spec §6.6), so teardown doesn't depend on
361            // the pane being mounted to send `deletesubblock`.
362            close_sub_blocks(state, &before.sub_blocks, deadline).await;
363            report.survivors = close_cli(state, block_id, &who, &before, deadline, started).await;
364            if policy.stop_container {
365                report.stopped_container = stop_container(state, block_id, &before, deadline).await;
366            }
367            report.containers_left = compose_containers_left(state, block_id, &before, deadline).await;
368        }
369    }
370    report
371}
372
373/// Stop a container agent's container (spec §6.7), unless another live pane
374/// runs the same agent in it. Docker's own SIGTERM → SIGKILL grace is bounded
375/// by what's left of the teardown's deadline.
376async fn stop_container(
377    state: &AppState,
378    block_id: &str,
379    before: &AgentResources,
380    deadline: std::time::Instant,
381) -> Option<String> {
382    let name = before.container.clone()?;
383    let shared = {
384        let (st, id, n) = (state.clone(), block_id.to_string(), name.clone());
385        tokio::task::spawn_blocking(move || agent_resources::container_shared(&st, &id, &n)).await.unwrap_or(true)
386    };
387    if shared {
388        tracing::info!(block_id = %block_id, container = %name, "agent_teardown: container still in use; kept");
389        return None;
390    }
391    let cm = state.container_manager.get().await?;
392    let grace = deadline.saturating_duration_since(std::time::Instant::now()).as_secs().max(1) as i64;
393    match cm.stop(&name, grace).await {
394        Ok(()) => {
395            publish_shutdown(state, block_id, "container", format!("stopped container {name}"), ShutdownExtra::default());
396            Some(name)
397        }
398        Err(e) => {
399            tracing::warn!(block_id = %block_id, container = %name, error = %e, "agent_teardown: container stop failed");
400            None
401        }
402    }
403}
404
405/// Running Docker Compose containers started from the agent's working
406/// directory (`docker compose up -d`): they belong to the Docker daemon, not
407/// the agent's process tree, so no tracker ends them. Reported, not stopped:
408/// a person may be using them too. Skipped when the agent works in `/` or
409/// the home directory, where every project would match.
410async fn compose_containers_left(
411    state: &AppState,
412    block_id: &str,
413    before: &AgentResources,
414    deadline: std::time::Instant,
415) -> Vec<String> {
416    let dir = std::path::PathBuf::from(&before.cwd);
417    let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from);
418    if before.cwd.is_empty() || dir.parent().is_none() || home.as_deref() == Some(dir.as_path()) {
419        return Vec::new();
420    }
421    let Some(cm) = state.container_manager.get().await else { return Vec::new() };
422    // A daemon that accepts but never answers must not hold up the close.
423    let wait = deadline.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_secs(1));
424    let names = match tokio::time::timeout(wait, cm.running_compose_containers_under(&dir)).await {
425        Ok(Ok(names)) => names,
426        Ok(Err(e)) => {
427            tracing::warn!(block_id = %block_id, error = %e, "agent_teardown: listing Compose containers failed");
428            return Vec::new();
429        }
430        Err(_) => {
431            tracing::warn!(block_id = %block_id, "agent_teardown: Docker didn't list Compose containers in time");
432            return Vec::new();
433        }
434    };
435    for name in &names {
436        publish_shutdown(
437            state,
438            block_id,
439            "container",
440            format!("container {name} (Docker Compose, in this agent's folder) is still running"),
441            ShutdownExtra { container: Some(name.clone()), outcome: Some("left".into()), ..Default::default() },
442        );
443    }
444    names
445}
446
447/// Stop the agent's running `run_in_background` tasks, each with its process
448/// tree, after re-checking each PID is still that task. Returns how many.
449async fn stop_background_tasks(before: &AgentResources) -> usize {
450    let tasks = before.background_tasks.clone();
451    tokio::task::spawn_blocking(move || {
452        let pids = agent_resources::live_background_pids(&tasks);
453        for pid in &pids {
454            tracing::info!(pid, "agent_teardown: stopping a background task");
455            agentmux_common::process::kill_process_group(*pid);
456        }
457        pids.len()
458    })
459    .await
460    .unwrap_or(0)
461}
462
463/// Close a parent's sub-blocks (PtyShell drawers) the way a pane closes,
464/// concurrently and under the parent's deadline.
465async fn close_sub_blocks(state: &AppState, sub_blocks: &[String], deadline: std::time::Instant) {
466    if sub_blocks.is_empty() {
467        return;
468    }
469    let subs = sub_blocks.iter().map(|id| Box::pin(run_one(state, id, Policy::close(), deadline)));
470    futures_util::future::join_all(subs).await;
471    // Their processes are gone. Nothing deletes a drawer's record through a
472    // close saga's `finish_close`, so lift the respawn guard here: if the
473    // parent's close then fails, its drawer can be reopened.
474    for id in sub_blocks {
475        blockcontroller::unmark_closing(id);
476    }
477}
478
479/// The per-agent close (`SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md`
480/// §4.2), then the verify step (agent teardown spec §6.2 step 8). Returns the
481/// survivors.
482async fn close_cli(
483    state: &AppState,
484    block_id: &str,
485    who: &AgentIdentity,
486    before: &AgentResources,
487    deadline: std::time::Instant,
488    started: std::time::Instant,
489) -> Vec<agent_resources::ProcessEntry> {
490    let label = if who.agent_id.is_empty() { "agent".to_string() } else { who.agent_id.clone() };
491    // 1. Stop routing input: no respawn (resync_controller refuses), no
492    //    jekt/muxbus delivery, and — once out of CONTROLLER_REGISTRY — no
493    //    AgentInput either.
494    blockcontroller::mark_closing(block_id);
495    state.reactive_handler.unregister_block(block_id);
496    let ctrl = blockcontroller::take_controller(block_id);
497    // 2–4. End the turn, ask the process to exit, force-kill at the deadline;
498    //      resolves once it has actually exited.
499    let (controller_type, outcome) = match ctrl {
500        Some(ctrl) => {
501            if ctrl.get_runtime_status().turn_active {
502                publish_shutdown(state, block_id, "interrupt", "turn interrupted".into(), ShutdownExtra::default());
503            }
504            let outcome = ctrl.shutdown(deadline).await;
505            (ctrl.controller_type().to_string(), outcome)
506        }
507        None => (String::new(), blockcontroller::StopOutcome::NotRunning),
508    };
509    let (step, text, extra) = exit_line(&label, &outcome, started.elapsed());
510    publish_shutdown(state, block_id, step, text, extra);
511    // 4b. What it started gets the same chance: SIGTERM to its whole tree,
512    //     then a short wait, before the release below kills what's left.
513    terminate_members(state, block_id, deadline).await;
514    // 5. Only now drop the process tracker — anything the agent itself
515    //    started dies with it, but the agent got to exit first. Whatever is
516    //    still alive at this point is what the release kills.
517    let alive: std::collections::HashSet<u32> =
518        state.process_tracker.list_block(block_id).into_iter().map(|p| p.pid).collect();
519    blockcontroller::release_block_processes(block_id);
520    for p in &before.processes {
521        let killed = alive.contains(&p.pid);
522        publish_shutdown(
523            state,
524            block_id,
525            "process",
526            process_line(&p.command, p.pid, killed),
527            process_extra(p.pid, &p.command, if killed { "killed" } else { "stopped" }),
528        );
529    }
530    // 5b. Verify: everything in the snapshot is gone, else force it once
531    //     more and report what's left.
532    let mut survivors = verify_gone(&before.processes).await;
533    for p in &survivors {
534        publish_shutdown(
535            state,
536            block_id,
537            "survivor",
538            format!("still running: {} (pid {})", short_command(&p.command), p.pid),
539            process_extra(p.pid, &p.command, "survived"),
540        );
541    }
542    // 5c. What still carries the agent's env tag got out of its tracker
543    //     (started through systemd or launchd, or a best-effort miss).
544    //     Reported, not killed: it may be an app the agent opened for the user.
545    for p in escaped(block_id, &survivors).await {
546        publish_shutdown(
547            state,
548            block_id,
549            "survivor",
550            format!("still running, outside the agent's tracking: {} (pid {})", short_command(&p.command), p.pid),
551            process_extra(p.pid, &p.command, "escaped"),
552        );
553        survivors.push(p);
554    }
555    blockcontroller::mark_closing_stopped(block_id);
556    crate::backend::container_credential::revoke_block(block_id);
557    // 6. Save final state.
558    save_final_state(state, block_id);
559    publish_shutdown(state, block_id, "saved", "conversation saved".into(), ShutdownExtra::default());
560    publish_shutdown(state, block_id, "done", "closing".into(), ShutdownExtra::default());
561    tracing::info!(
562        block_id = %block_id,
563        controller_type = %controller_type,
564        outcome = outcome.as_str(),
565        elapsed_ms = started.elapsed().as_millis() as u64,
566        survivors = survivors.len(),
567        "agent_shutdown"
568    );
569    survivors
570}
571
572/// The longest wait after SIGTERM to what an agent started; never past the
573/// teardown's deadline.
574const MEMBERS_GRACE_MAX: std::time::Duration = std::time::Duration::from_secs(2);
575
576/// Ask everything in the block's tree to exit and wait until it has, or the
577/// grace passes. Returns at once where the tracker has no graceful signal
578/// (Windows), nothing is left, or the deadline has passed (the release's
579/// kill follows straight away).
580async fn terminate_members(state: &AppState, block_id: &str, deadline: std::time::Instant) {
581    let grace = deadline.saturating_duration_since(std::time::Instant::now()).min(MEMBERS_GRACE_MAX);
582    if grace.is_zero() {
583        return;
584    }
585    let registry = state.process_tracker.clone();
586    let id = block_id.to_string();
587    let count = move || {
588        let (r, id) = (registry.clone(), id.clone());
589        tokio::task::spawn_blocking(move || r.member_count(&id))
590    };
591    if count().await.unwrap_or(0) == 0 {
592        return;
593    }
594    let (r, id) = (state.process_tracker.clone(), block_id.to_string());
595    if !tokio::task::spawn_blocking(move || r.terminate(&id)).await.unwrap_or(false) {
596        return;
597    }
598    let until = std::time::Instant::now() + grace;
599    while std::time::Instant::now() < until {
600        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
601        if count().await.unwrap_or(0) == 0 {
602            return;
603        }
604    }
605}
606
607/// Live processes tagged with `block_id` (`process_tracker::scan`) that
608/// aren't already in `known`. Two scans [`ESCAPE_SETTLE`] apart: one the
609/// tracker's kill is still ending shows in the first only.
610async fn escaped(block_id: &str, known: &[agent_resources::ProcessEntry]) -> Vec<agent_resources::ProcessEntry> {
611    let scan = |id: String| async move {
612        tokio::task::spawn_blocking(move || crate::backend::process_tracker::scan::tagged(&id))
613            .await
614            .unwrap_or_default()
615    };
616    let first = scan(block_id.to_string()).await;
617    if first.is_empty() {
618        return Vec::new();
619    }
620    tokio::time::sleep(ESCAPE_SETTLE).await;
621    scan(block_id.to_string())
622        .await
623        .into_iter()
624        .filter(|p| first.iter().any(|f| f.pid == p.pid && f.started_at_ms == p.started_at_ms))
625        .filter(|p| !known.iter().any(|k| k.pid == p.pid))
626        .map(|p| agent_resources::ProcessEntry { pid: p.pid, command: p.command, started_at_ms: p.started_at_ms })
627        .collect()
628}
629
630const ESCAPE_SETTLE: std::time::Duration = std::time::Duration::from_millis(300);
631
632/// How long the verify step waits for the tracker's release to take effect,
633/// before and after its one forced kill per survivor.
634const VERIFY_WAIT: std::time::Duration = std::time::Duration::from_millis(1500);
635
636/// Spec §6.2 step 8: wait for every process in the snapshot to be gone. Any
637/// still running (same PID and start time) gets one forced kill of its tree;
638/// whatever is left after that is returned. Never kills a PID the OS has
639/// reused: `survivors` re-checks the start time right before the kill.
640async fn verify_gone(before: &[agent_resources::ProcessEntry]) -> Vec<agent_resources::ProcessEntry> {
641    if before.is_empty() {
642        return Vec::new();
643    }
644    let mut left = wait_gone(before.to_vec()).await;
645    if left.is_empty() {
646        return left;
647    }
648    for p in &left {
649        tracing::warn!(pid = p.pid, command = %p.command, "agent_teardown: forcing a survivor");
650        let (pid, started) = (p.pid, p.started_at_ms);
651        let _ = tokio::task::spawn_blocking(move || agent_resources::force_kill_tree(pid, started)).await;
652    }
653    left = wait_gone(left).await;
654    left
655}
656
657/// Poll until none of `procs` is running or [`VERIFY_WAIT`] passes; returns
658/// the ones still running.
659async fn wait_gone(mut procs: Vec<agent_resources::ProcessEntry>) -> Vec<agent_resources::ProcessEntry> {
660    let until = std::time::Instant::now() + VERIFY_WAIT;
661    loop {
662        procs = tokio::task::spawn_blocking(move || agent_resources::survivors(&procs)).await.unwrap_or_default();
663        if procs.is_empty() || std::time::Instant::now() >= until {
664            return procs;
665        }
666        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
667    }
668}
669
670/// Release the agent's work claims, so the items go back to the pool now
671/// rather than when the 120 s lease expires, and list the crons that target
672/// it (kept: a cron is a deliberate schedule that resumes when the agent is
673/// reopened). SQLite reads and writes, off the async workers.
674async fn release_claims_and_list_crons(state: &AppState, who: &AgentIdentity, reason: &'static str) -> (usize, Vec<String>) {
675    let (identity_store, shared_store) = (state.identity_store.clone(), state.shared_store.clone());
676    let (a, u) = (who.agent_id.clone(), who.uid.clone());
677    let result = tokio::task::spawn_blocking(move || {
678        let now = agentmux_common::time::now_ms();
679        let items = identity_store.work_queue_claimed_by(&a, u.as_deref().unwrap_or("")).unwrap_or_default();
680        let released = agent_resources::claims_held_by(&items, &a, u.as_deref())
681            .into_iter()
682            .filter(|w| {
683                matches!(
684                    identity_store.work_queue_release(&w.id, &w.claimed_by, &w.claimed_by_uid, w.attempts, reason, now),
685                    Ok(Some(_))
686                )
687            })
688            .count();
689        let crons = shared_store
690            .and_then(|s| s.cron_list().ok())
691            .map(|jobs| agent_resources::crons_targeting(&jobs, &a, u.as_deref()))
692            .unwrap_or_default();
693        (released, crons)
694    })
695    .await
696    .unwrap_or_default();
697    if result.0 > 0 {
698        crate::server::work_queue::publish_changed(state);
699    }
700    result
701}
702
703#[cfg(test)]
704mod tests {
705    use super::*;
706
707    #[test]
708    fn policies_encode_todays_scopes() {
709        assert_eq!(Policy::close(), Policy { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true });
710        assert_eq!(Policy::quit(), Policy { cli: CliStop::Graceful, release_claims: true, stop_shell_sessions: true, stop_background: false, stop_container: true });
711        assert_eq!(Policy::stop(false, false).cli, CliStop::StopOnly { graceful: false });
712        // Stop releases claims and keeps shells; background tasks as asked.
713        assert!(Policy::stop(true, false).release_claims && !Policy::stop(true, false).stop_shell_sessions);
714        assert!(Policy::stop(true, true).stop_background && !Policy::stop(true, false).stop_background);
715        // Containers stop with a closing agent, never on Stop or a restart.
716        assert!(Policy::close().stop_container && Policy::quit().stop_container && Policy::app_exit().stop_container);
717        assert!(!Policy::stop(true, true).stop_container && !Policy::replace().stop_container);
718        // The watchdog's stop is the CLI only.
719        let w = Policy::stop_cli_only(true);
720        assert!(!w.release_claims && !w.stop_shell_sessions && !w.stop_background);
721        assert_eq!(Policy::replace(), Policy { cli: CliStop::Replace, release_claims: false, stop_shell_sessions: false, stop_background: false, stop_container: false });
722        assert_eq!(Policy::app_exit(), Policy { cli: CliStop::Graceful, release_claims: false, stop_shell_sessions: true, stop_background: false, stop_container: true });
723        // App exit covers the CLI's own grace and the shell sweep.
724        assert!(APP_EXIT_CAP.saturating_sub(SHELL_SWEEP_GRACE) > blockcontroller::SHUTDOWN_GRACE);
725    }
726
727    /// The single path (spec §3 G1, §9.1): outside this module and the
728    /// process machinery itself, nothing in srv stops an agent's processes.
729    #[test]
730    fn nothing_else_stops_an_agent() {
731        // (file suffix, why it may) — every entry is a definition, the
732        // machinery's own internals, or a phase-2 consumer named in the spec.
733        const ALLOWED: &[(&str, &str)] = &[
734            ("sagas/agent_teardown.rs", "the one path"),
735            ("backend/blockcontroller/mod.rs", "defines release_block_processes and delete_controller"),
736            ("backend/blockcontroller/persistent/", "the controller's own shutdown internals"),
737            ("backend/blockcontroller/shell/", "the controller's own stop internals"),
738            ("backend/process_tracker/", "the tracker itself"),
739            ("backend/shell_node.rs", "ShellSessionRegistry's own stop/stop_all"),
740            ("server/http_shell.rs", "the ShellStop tool: the agent stopping one shell on purpose"),
741        ];
742        const PATTERNS: &[&str] = &[
743            "release_block_processes(",
744            "shell_sessions.stop(",
745            ".stop_all()",
746            ".shutdown(deadline",
747            "ctrl.stop(",
748            ".stop_for_replace(",
749            "delete_controller(",
750        ];
751        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
752        let mut offenders = Vec::new();
753        let mut stack = vec![root.clone()];
754        while let Some(dir) = stack.pop() {
755            for entry in std::fs::read_dir(&dir).unwrap() {
756                let path = entry.unwrap().path();
757                if path.is_dir() {
758                    stack.push(path);
759                    continue;
760                }
761                if path.extension().and_then(|e| e.to_str()) != Some("rs") {
762                    continue;
763                }
764                let rel = path.strip_prefix(&root).unwrap().to_string_lossy().replace('\\', "/");
765                // Test modules in their own files (`tests.rs`, `tests/`).
766                let is_test_file = rel.ends_with("tests.rs") || rel.contains("/tests/");
767                if is_test_file || ALLOWED.iter().any(|(a, _)| rel.starts_with(a) || rel == *a) {
768                    continue;
769                }
770                let text = std::fs::read_to_string(&path).unwrap();
771                // Code only: skip comment lines and everything from a test module on.
772                let code = text.split("#[cfg(test)]").next().unwrap_or("");
773                for (i, line) in code.lines().enumerate() {
774                    let t = line.trim_start();
775                    if t.starts_with("//") {
776                        continue;
777                    }
778                    if let Some(p) = PATTERNS.iter().find(|p| line.contains(*p)) {
779                        offenders.push(format!("{rel}:{}: `{p}`", i + 1));
780                    }
781                }
782            }
783        }
784        assert!(
785            offenders.is_empty(),
786            "an agent's processes are stopped only by sagas::agent_teardown (spec SPEC_AGENT_TEARDOWN_SINGLE_PATH §3 G1); \
787             route these through it:\n{}",
788            offenders.join("\n")
789        );
790    }
791}