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}