Skip to main content

agentmux_srv/backend/process_tracker/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent-spawned process tracking.
5//!
6//! Gives the host a complete, authoritative view of what each agent CLI
7//! has forked — backgrounded shells, dev servers, Docker containers,
8//! file watchers, nested bash/python/node children, etc. The goal is
9//! end-user visibility: a user running multiple agents can see in one
10//! place what's still running on their machine, and kill it reliably
11//! when they're done.
12//!
13//! The API is a platform-agnostic trait, one impl per platform. Terminals a
14//! person types in get the no-op `StubTracker` everywhere but Windows (see
15//! `AgentProcessRegistry::ensure_tracker_kind`).
16//!
17//! | Platform | Impl            | Mechanism                                  | Confidence |
18//! |----------|-----------------|--------------------------------------------|------------|
19//! | Windows  | `JobObjectTracker` | `CreateJobObject` + `AssignProcessToJobObject` + `TerminateJobObject` | high       |
20//! | Linux    | `CgroupTracker` | cgroup v2 per agent in srv's delegated user scope; `cgroup.procs` / `cgroup.kill` | high |
21//! | Linux, no delegated cgroup; macOS | `ScanTracker` | process table: descendants of the CLI + the inherited `AGENTMUX_BLOCKID` env tag | best effort |
22//! | other    | `StubTracker`   | no-op                                                          | none       |
23//!
24//! The frontend's swarm panel surfaces the confidence level so users know
25//! when tracking may miss escaped descendants.
26//!
27//! See `docs/analysis/agent-spawned-process-tracking-2026-10-07.md`.
28
29use std::collections::{HashMap, HashSet};
30use std::sync::Arc;
31
32pub mod registry;
33
34#[cfg(windows)]
35pub mod windows;
36
37#[cfg(target_os = "linux")]
38pub mod cgroup_linux;
39
40pub mod scan;
41
42// `stub` is defined inline below; there is no separate `stub` file. A file-form
43// `pub mod stub;` here would collide with it (E0428) and break `task dev`.
44
45/// A single process tracked by the host — PID + metadata enriched
46/// per-platform. The frontend renders one row per entry.
47#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
48#[ts(export, export_to = "../../../frontend/types/rpc/")]
49pub struct TrackedProcess {
50    pub pid: u32,
51    /// Full command line, or best approximation. May be empty if the
52    /// platform doesn't expose it cheaply (macOS without `libproc`).
53    pub command: String,
54    /// Working-set / RSS in bytes. 0 if unavailable.
55    #[ts(type = "number")]
56    pub rss_bytes: u64,
57    /// Unix ms of process creation, 0 if unavailable.
58    #[ts(type = "number")]
59    pub started_at_ms: u64,
60    /// Parent PID, if the platform exposes it. Drives
61    /// [`agent_started`]'s ancestry walk.
62    pub parent_pid: Option<u32>,
63    /// The executable alone (path or name), which [`agent_started`]
64    /// classifies by: `command` may be a whole argv (`/bin/bash -c npm
65    /// test`). Empty means `command` is the executable.
66    #[serde(skip)]
67    #[ts(skip)]
68    pub exe: String,
69}
70
71impl TrackedProcess {
72    fn image(&self) -> String {
73        image_name(if self.exe.is_empty() { &self.command } else { &self.exe })
74    }
75}
76
77/// Lowercased executable name without directory or `.exe` suffix —
78/// `C:\Program Files\Git\bin\bash.exe` → `bash`.
79fn image_name(command: &str) -> String {
80    let base = command.rsplit(['\\', '/']).next().unwrap_or(command);
81    let base = base.to_ascii_lowercase();
82    base.strip_suffix(".exe").map(str::to_string).unwrap_or(base)
83}
84
85/// Interpreters an agent runs its tool commands through (Claude Code's Bash
86/// tool spawns `bash.exe -c ...` directly under `claude.exe`; Codex/Gemini
87/// use `powershell`/`cmd`).
88fn is_shell(p: &TrackedProcess) -> bool {
89    matches!(
90        p.image().as_str(),
91        "bash" | "sh" | "dash" | "zsh" | "fish" | "cmd" | "powershell" | "pwsh" | "nu"
92    )
93}
94
95/// Filter a tracker's raw membership down to the processes the agent
96/// started through its tools — what the Swarm list, the pane-close
97/// confirmation and the process events should count.
98///
99/// The raw job also holds the agent's own plumbing: the CLI itself
100/// (`roots` — every PID handed to `assign_process`), its `conhost.exe`, and
101/// MCP servers such as `agentmux-mcp.exe` (direct non-shell children of the
102/// root, plus their own conhosts/children). Counting those made every idle
103/// agent report ~3 processes and every pane close ask for confirmation.
104///
105/// Rule: drop roots and `conhost`; keep a process iff walking its parents
106/// inside the job passes through a shell before reaching a root. A root
107/// that is itself a shell (terminal blocks) counts as that shell. A chain
108/// that hits a parent no longer in the job — a launching shell that exited
109/// (`npm run dev &`), or a root that exited (a terminal's shell, a
110/// respawned CLI) — is kept: those orphans are exactly what the user needs
111/// to see.
112pub fn agent_started(members: Vec<TrackedProcess>, roots: &HashSet<u32>) -> Vec<TrackedProcess> {
113    let by_pid: HashMap<u32, &TrackedProcess> = members.iter().map(|p| (p.pid, p)).collect();
114    let keep: HashSet<u32> = members
115        .iter()
116        .filter(|p| !roots.contains(&p.pid) && p.image() != "conhost")
117        .filter(|p| {
118            let mut cur = *p;
119            // Bounded walk: a PID-reuse cycle can't spin forever.
120            for _ in 0..=members.len() {
121                if is_shell(cur) {
122                    return true;
123                }
124                let Some(parent) = cur.parent_pid else { return true };
125                if roots.contains(&parent) {
126                    // A root that has exited leaves an orphan like any other
127                    // missing parent — keep it (ReAgent P1 on #3430).
128                    return by_pid.get(&parent).is_none_or(|r| is_shell(r));
129                }
130                match by_pid.get(&parent) {
131                    Some(next) => cur = next,
132                    None => return true,
133                }
134            }
135            false
136        })
137        .map(|p| p.pid)
138        .collect();
139    members.into_iter().filter(|p| keep.contains(&p.pid)).collect()
140}
141
142/// Opaque per-agent handle returned by the tracker when we wrap a spawn.
143/// Held inside `AgentProcessRegistry` for the lifetime of the pane;
144/// dropped when the pane closes or the agent exits.
145#[allow(dead_code)]
146pub trait TrackerHandle: Send + Sync {
147    /// Add a freshly-spawned process to the tracked tree. Called by the
148    /// controller immediately after `tokio::process::Command::spawn`.
149    /// Descendants created AFTER this call are caught automatically;
150    /// descendants created BEFORE (in the ~1ms race window) escape.
151    /// No-op in the stub impl — platforms without a real tracker
152    /// silently accept the PID and move on.
153    fn assign_process(&self, pid: u32) -> Result<(), String>;
154
155    /// Enumerate the current members of this tracked tree.
156    ///
157    /// Must be cheap enough to poll every ~2s. On Windows this is a
158    /// single Job Object query; the planned Linux and macOS impls would
159    /// read `cgroup.procs` and scan sysctl respectively.
160    fn list_members(&self) -> Vec<TrackedProcess>;
161
162    /// Forcibly terminate every process in this tracked tree.
163    fn kill_tree(&self);
164
165    /// Terminate a single process by PID, if it's a member of this tree.
166    /// Returns `true` if the PID was known and the kill was attempted.
167    fn kill_pid(&self, pid: u32) -> bool;
168
169    /// Describes how confidently this platform tracks descendants.
170    /// Surfaced to the UI so the user can tell when tracking is
171    /// best-effort and escape-prone.
172    fn confidence(&self) -> TrackingConfidence;
173
174    /// Run every process in this tree at below-normal CPU priority (`on`), or
175    /// back at normal. Descendants inherit it and cannot raise it themselves.
176    /// Windows only (a Job Object priority-class limit); a no-op elsewhere.
177    /// See `docs/analysis/ANALYSIS_SRV_HTTP_STALL_IO_DRIVER_STARVATION_2026_09_26.md` §8.3.
178    fn set_below_normal_priority(&self, _on: bool) -> Result<(), String> {
179        Ok(())
180    }
181
182    /// Ask every member to exit (SIGTERM): the graceful step a teardown
183    /// takes before [`kill_tree`](Self::kill_tree). `false` where the
184    /// platform has no graceful signal for a whole tree yet (Windows), so
185    /// there is nothing to wait for.
186    fn terminate(&self) -> bool {
187        false
188    }
189
190    /// How many processes the tree holds right now, the agent's own CLI and
191    /// plumbing included: what a teardown waits to reach zero.
192    fn member_count(&self) -> usize {
193        self.list_members().len()
194    }
195
196    /// The tree's PIDs alone, plumbing included, without the per-process
197    /// enrichment `list_members` does: what Tower reads every refresh
198    /// (SPEC_TOWER_TASK_MANAGER_PANE_2026_10_08.md §5).
199    fn member_pids(&self) -> Vec<u32> {
200        self.list_members().into_iter().map(|p| p.pid).collect()
201    }
202
203    /// CPU time the whole tree has used, in nanoseconds, **including members
204    /// that have exited** (a Job Object's accounting, a cgroup's `cpu.stat`),
205    /// so a burst of short-lived build processes isn't lost between two
206    /// samples. `None` where the platform keeps no such account.
207    fn cpu_time_ns(&self) -> Option<u64> {
208        None
209    }
210
211    /// Where a child about to be spawned joins the tree before it execs
212    /// (Linux: the cgroup's `cgroup.procs`), so nothing it forks can slip
213    /// out between spawn and [`assign_process`](Self::assign_process).
214    /// `None`: only the after-spawn assignment.
215    fn spawn_target(&self) -> Option<std::path::PathBuf> {
216        None
217    }
218}
219
220/// How reliable this platform's tracker is.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, ts_rs::TS)]
222#[ts(export, export_to = "../../../frontend/types/rpc/")]
223#[serde(rename_all = "snake_case")]
224pub enum TrackingConfidence {
225    /// Descendants can't escape the tracker. Windows Job Objects +
226    /// Linux cgroups v2.
227    #[cfg_attr(not(any(windows, target_os = "linux")), allow(dead_code))] // Only those two trackers build it.
228    High,
229    /// Descendants can escape via `setsid`, launchd, etc. macOS.
230    BestEffort,
231    /// Platform has no tracker. No guarantees.
232    None,
233}
234
235/// Factory: returns a platform-appropriate tracker handle that will
236/// accept the next-spawned process and everything it forks.
237///
238/// Call once per agent pane; reuse the handle across multiple turns of
239/// the `SubprocessController` so children from any turn are all tracked
240/// under the same umbrella.
241/// `agent`: the block runs an agent, not a terminal a person types in (see
242/// `AgentProcessRegistry::ensure_tracker_kind`). Every Unix tells them apart;
243/// Windows gives both a Job Object.
244pub fn new_tracker(block_id: &str, agent: bool) -> Arc<dyn TrackerHandle> {
245    #[cfg(windows)]
246    {
247        match windows::JobObjectTracker::new(block_id) {
248            Ok(t) => Arc::new(t),
249            Err(e) => {
250                tracing::error!(
251                    block_id = %block_id,
252                    error = %e,
253                    "[process-tracker] JobObjectTracker init failed — falling back to best-effort tracking"
254                );
255                if agent {
256                    Arc::new(scan::ScanTracker::new(block_id))
257                } else {
258                    Arc::new(stub::StubTracker)
259                }
260            }
261        }
262    }
263    #[cfg(target_os = "linux")]
264    {
265        if let Some(root) = cgroup_linux::root().filter(|_| agent) {
266            match cgroup_linux::CgroupTracker::new(root, block_id) {
267                Ok(t) => return Arc::new(t),
268                Err(e) => tracing::warn!(block_id = %block_id, error = %e, "[process-tracker] cgroup tracker init failed"),
269            }
270        }
271        if agent {
272            return Arc::new(scan::ScanTracker::new(block_id));
273        }
274        Arc::new(stub::StubTracker)
275    }
276    #[cfg(all(unix, not(target_os = "linux")))]
277    {
278        if agent {
279            return Arc::new(scan::ScanTracker::new(block_id));
280        }
281        Arc::new(stub::StubTracker)
282    }
283    #[cfg(not(any(windows, unix)))]
284    {
285        let _ = (block_id, agent);
286        Arc::new(stub::StubTracker)
287    }
288}
289
290pub mod stub {
291    //! No-op tracker used on unsupported platforms, and on Windows when
292    //! `JobObjectTracker::new` fails (e.g. the process is not elevated enough
293    //! to create a job object). All operations succeed silently;
294    //! `list_members` always returns empty. Confidence reports `None` so the
295    //! UI can inform the user that tracking is disabled.
296
297    use super::{TrackedProcess, TrackerHandle, TrackingConfidence};
298
299    pub struct StubTracker;
300
301    impl TrackerHandle for StubTracker {
302        fn assign_process(&self, _pid: u32) -> Result<(), String> {
303            Ok(())
304        }
305        fn list_members(&self) -> Vec<TrackedProcess> {
306            Vec::new()
307        }
308        fn kill_tree(&self) {}
309        fn kill_pid(&self, _pid: u32) -> bool {
310            false
311        }
312        fn confidence(&self) -> TrackingConfidence {
313            TrackingConfidence::None
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    fn proc(pid: u32, parent: Option<u32>, command: &str) -> TrackedProcess {
323        TrackedProcess {
324            pid,
325            command: command.to_string(),
326            rss_bytes: 0,
327            started_at_ms: 0,
328            parent_pid: parent,
329            exe: String::new(),
330        }
331    }
332
333    fn pids(v: &[TrackedProcess]) -> Vec<u32> {
334        let mut p: Vec<u32> = v.iter().map(|p| p.pid).collect();
335        p.sort();
336        p
337    }
338
339    /// The live tree of an idle Claude agent (observed after #3425): nothing
340    /// in it was started by the agent.
341    fn idle_claude() -> Vec<TrackedProcess> {
342        vec![
343            proc(10, Some(1), r"C:\Users\u\.local\bin\claude.exe"),
344            proc(11, Some(10), r"C:\Windows\System32\conhost.exe"),
345            proc(12, Some(10), r"C:\Program Files\AgentMux\agentmux-mcp.exe"),
346            proc(13, Some(12), r"C:\Windows\System32\conhost.exe"),
347        ]
348    }
349
350    #[test]
351    fn idle_agent_has_no_agent_started_processes() {
352        assert!(agent_started(idle_claude(), &HashSet::from([10])).is_empty());
353    }
354
355    #[test]
356    fn bash_tool_command_and_its_descendants_count() {
357        let mut members = idle_claude();
358        members.push(proc(20, Some(10), r"C:\Program Files\Git\bin\bash.exe"));
359        members.push(proc(21, Some(20), r"C:\Program Files\nodejs\node.exe"));
360        members.push(proc(22, Some(21), r"C:\Program Files\nodejs\node.exe"));
361        assert_eq!(pids(&agent_started(members, &HashSet::from([10]))), vec![20, 21, 22]);
362    }
363
364    #[test]
365    fn mcp_server_and_its_children_do_not_count() {
366        let mut members = idle_claude();
367        members.push(proc(30, Some(10), r"C:\Program Files\nodejs\node.exe"));
368        members.push(proc(31, Some(30), r"C:\Program Files\nodejs\node.exe"));
369        assert!(agent_started(members, &HashSet::from([10])).is_empty());
370    }
371
372    #[test]
373    fn orphan_whose_launching_shell_exited_still_counts() {
374        // `npm run dev &` — the bash that launched it is gone, the server lives on.
375        let mut members = idle_claude();
376        members.push(proc(40, Some(39), r"C:\Program Files\nodejs\node.exe"));
377        assert_eq!(pids(&agent_started(members, &HashSet::from([10]))), vec![40]);
378    }
379
380    #[test]
381    fn orphan_of_an_exited_root_still_counts() {
382        // A terminal's shell (root 80) backgrounded a server, then exited.
383        let members = vec![proc(81, Some(80), r"C:\Program Files\nodejs\node.exe")];
384        assert_eq!(pids(&agent_started(members, &HashSet::from([80]))), vec![81]);
385    }
386
387    #[test]
388    fn terminal_block_rooted_at_a_shell_counts_its_direct_children() {
389        let members = vec![
390            proc(50, Some(1), r"C:\Program Files\PowerShell\7\pwsh.exe"),
391            proc(51, Some(50), r"C:\Windows\System32\conhost.exe"),
392            proc(52, Some(50), r"C:\Program Files\nodejs\node.exe"),
393        ];
394        assert_eq!(pids(&agent_started(members, &HashSet::from([50]))), vec![52]);
395    }
396
397    #[test]
398    fn respawned_agent_excludes_every_root() {
399        // Old and new CLI PIDs were both assigned; neither is agent-started.
400        let mut members = idle_claude();
401        members.push(proc(60, Some(1), r"C:\Users\u\.local\bin\claude.exe"));
402        assert!(agent_started(members, &HashSet::from([10, 60])).is_empty());
403    }
404
405    /// Linux and macOS report the whole argv as `command`; the shell is
406    /// recognised from `exe`.
407    #[test]
408    fn a_shell_reported_with_its_argv_is_still_a_shell() {
409        let argv = |pid, parent, exe: &str, command: &str| TrackedProcess { exe: exe.to_string(), ..proc(pid, parent, command) };
410        let members = vec![
411            argv(10, Some(1), "/usr/bin/node", "node /usr/lib/claude/cli.js --input-format stream-json"),
412            argv(20, Some(10), "/bin/bash", "/bin/bash -c npm test"),
413            argv(21, Some(20), "/usr/bin/node", "node /repo/node_modules/.bin/vitest"),
414        ];
415        assert_eq!(pids(&agent_started(members, &HashSet::from([10]))), vec![20, 21]);
416    }
417
418    #[test]
419    fn parent_pid_cycle_terminates() {
420        let members = vec![proc(70, Some(71), r"C:\x\a.exe"), proc(71, Some(70), r"C:\x\b.exe")];
421        assert!(agent_started(members, &HashSet::new()).is_empty());
422    }
423}