Skip to main content

agentmux_srv/backend/process_tracker/
registry.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `AgentProcessRegistry` — app-wide map from block_id → tracker handle.
5//!
6//! Created once at host startup, passed into each `SubprocessController`
7//! / `PersistentSubprocessController` instance so it can wrap spawns
8//! in its per-block job. Polled periodically from a background task
9//! that emits `agent:process-added` / `agent:process-exited` events to
10//! the frontend's swarm activity panel.
11//!
12//! Centralizing this here (rather than one tracker per controller)
13//! means the lifetime of the tracker matches the lifetime of the pane
14//! — multiple turns on the same block share the same job, so
15//! descendants from turn N are still visible on turn N+1.
16
17use std::collections::{HashMap, HashSet};
18use std::sync::{Arc, OnceLock};
19
20use parking_lot::Mutex;
21
22use super::{agent_started, new_tracker, TrackedProcess, TrackerHandle, TrackingConfidence};
23use crate::backend::mps;
24
25/// Host-wide registry, set once at startup. Exposed as a global so
26/// `SubprocessController` / `PersistentSubprocessController` can reach
27/// it without threading an `Arc` through every constructor + test site.
28/// Tests that don't initialize it see `None` and silently skip
29/// tracker registration — the job-object spawn path no-ops cleanly.
30static GLOBAL: OnceLock<Arc<AgentProcessRegistry>> = OnceLock::new();
31
32pub fn set_global(registry: Arc<AgentProcessRegistry>) {
33    let _ = GLOBAL.set(registry);
34}
35
36pub fn global() -> Option<Arc<AgentProcessRegistry>> {
37    GLOBAL.get().cloned()
38}
39
40/// Assign a freshly-spawned child PID to `block_id`'s tracker, creating the
41/// tracker if needed. The single call site every `Controller` impl should use
42/// immediately after its own spawn — no-ops silently if the registry global
43/// isn't initialized (tests), and with a warn log if the platform tracker
44/// rejects the PID, since this is opportunistic enrichment, not a liveness signal on
45/// its own (see `broker::process::ProcessStatus`'s own doc comment).
46pub fn track_spawned(block_id: &str, pid: u32) {
47    track(block_id, pid, false);
48}
49
50fn track(block_id: &str, pid: u32, agent: bool) {
51    let Some(registry) = global() else { return };
52    if let Err(e) = registry.assign_kind(block_id, pid, agent) {
53        tracing::warn!(
54            block_id = %block_id,
55            pid = pid,
56            err = %e,
57            "[process-tracker] assign_process failed"
58        );
59    }
60}
61
62/// Put a process srv spawned on an agent's behalf (`Shell()`, `!cmd`) into
63/// that agent's tracker, so it dies with the agent like anything the agent
64/// started itself (SPEC_AGENT_TEARDOWN_SINGLE_PATH_2026_10_01.md §6.5). Unlike
65/// [`track_spawned`] it never creates a tracker (a block with no agent process
66/// has nothing to join) and doesn't mark the PID as a root, so the process
67/// still counts as something the agent started. Best-effort, like
68/// [`track_spawned`].
69pub fn track_adopted(block_id: &str, pid: u32) {
70    if block_id.is_empty() {
71        return;
72    }
73    let Some(registry) = global() else { return };
74    if let Err(e) = registry.adopt(block_id, pid) {
75        tracing::warn!(block_id = %block_id, pid = pid, err = %e, "[process-tracker] adopt failed");
76    }
77}
78
79/// Make a child about to be spawned for `block_id` join its tracker before it
80/// execs, so nothing it forks escapes between spawn and [`track_spawned`]
81/// (Linux: the agent's cgroup). `create`: start the block's tracker if it has
82/// none (an agent's own CLI); otherwise join only an existing one (`Shell()`,
83/// `!cmd`, like [`track_adopted`]). Still call `track_spawned*` /
84/// `track_adopted` after the spawn: that records the PID and covers platforms
85/// with no pre-exec placement.
86pub fn place_spawn(block_id: &str, cmd: &mut tokio::process::Command, create: bool) {
87    #[cfg(target_os = "linux")]
88    if let Some(target) = spawn_target(block_id, create) {
89        super::cgroup_linux::join_before_exec(cmd, target);
90    }
91    #[cfg(not(target_os = "linux"))]
92    let _ = (block_id, cmd, create);
93}
94
95/// How a spawn joins its block's tracker (see [`spawn_tracked`]).
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum Join {
98    /// The agent's own process: starts the block's tracker if needed and is
99    /// recorded as a root ([`track_spawned_agent`]).
100    Agent,
101    /// Started on an agent's behalf (`Shell()`, `!cmd`): joins an existing
102    /// tracker only ([`track_adopted`]).
103    Adopted,
104}
105
106/// Spawn `cmd` for `block_id` already inside the block's tracker, so nothing
107/// it starts can escape between spawn and assignment: on Linux it joins the
108/// cgroup before exec ([`place_spawn`]); on Windows it starts suspended, is
109/// assigned to the job, then resumed. The one way an agent-side tokio spawn
110/// should start (the Codex App Server, which spawns internally, uses
111/// [`place_spawn`] and [`track_spawned_agent`]).
112pub fn spawn_tracked(block_id: &str, cmd: &mut tokio::process::Command, join: Join) -> std::io::Result<tokio::process::Child> {
113    place_spawn(block_id, cmd, join == Join::Agent);
114    #[cfg(windows)]
115    let suspended = start_suspended(block_id, cmd, join == Join::Agent);
116    let child = cmd.spawn()?;
117    if let Some(pid) = child.id() {
118        match join {
119            Join::Agent => track_spawned_agent(block_id, pid),
120            Join::Adopted => track_adopted(block_id, pid),
121        }
122        // Resumed whether or not the assignment worked: a child left
123        // suspended would never run.
124        #[cfg(windows)]
125        if suspended {
126            if let Err(e) = agentmux_common::win32::resume_main_thread(pid) {
127                let mut child = child;
128                let _ = child.start_kill();
129                return Err(std::io::Error::other(format!("resume suspended child {pid}: {e}")));
130            }
131        }
132    }
133    Ok(child)
134}
135
136/// Make `cmd` start suspended when the block has (or, `create`, gets) a Job
137/// Object to put it in first. Agent-side spawns set no creation flag but
138/// `CREATE_NO_WINDOW`, which this keeps.
139#[cfg(windows)]
140fn start_suspended(block_id: &str, cmd: &mut tokio::process::Command, create: bool) -> bool {
141    use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
142    let Some(registry) = global() else { return false };
143    if block_id.is_empty() {
144        return false;
145    }
146    let tracker = if create {
147        Some(registry.ensure_tracker_kind(block_id, true))
148    } else {
149        registry.inner.lock().get(block_id).map(|e| e.tracker.clone())
150    };
151    if !tracker.is_some_and(|t| t.confidence() == TrackingConfidence::High) {
152        return false;
153    }
154    cmd.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
155    true
156}
157
158/// [`place_spawn`] for an agent's PTY spawn, which has no pre-exec hook: the
159/// command is wrapped to join first (`cgroup_linux::join_before_exec_pty`).
160pub fn place_pty_spawn(block_id: &str, cmd: &mut portable_pty::CommandBuilder) {
161    #[cfg(target_os = "linux")]
162    if let Some(target) = spawn_target(block_id, true) {
163        super::cgroup_linux::join_before_exec_pty(cmd, target);
164    }
165    #[cfg(not(target_os = "linux"))]
166    let _ = (block_id, cmd);
167}
168
169/// Where a spawn for `block_id` joins its agent's tree, if anywhere.
170#[cfg(target_os = "linux")]
171fn spawn_target(block_id: &str, create: bool) -> Option<std::path::PathBuf> {
172    if block_id.is_empty() {
173        return None;
174    }
175    let registry = global()?;
176    let tracker = if create {
177        Some(registry.ensure_tracker_kind(block_id, true))
178    } else {
179        registry.inner.lock().get(block_id).map(|e| e.tracker.clone())
180    };
181    tracker?.spawn_target()
182}
183
184/// Settings key: run agents' process trees at below-normal CPU priority so
185/// their builds and tests yield to AgentMux (and the user's foreground apps)
186/// under contention, while still using every idle core. Default on.
187pub const SETTING_AGENT_BELOW_NORMAL_PRIORITY: &str = "agent:belownormalpriority";
188
189/// [`track_spawned`] for an **agent** process: also applies the agent CPU
190/// priority policy to the block's whole tree. Terminal panes a human types in
191/// keep calling [`track_spawned`] and are never deprioritized.
192pub fn track_spawned_agent(block_id: &str, pid: u32) {
193    track(block_id, pid, true);
194    let Some(registry) = global() else { return };
195    let on = registry.agent_below_normal_priority();
196    if let Err(e) = registry.set_below_normal_priority(block_id, on) {
197        tracing::warn!(block_id = %block_id, err = %e, "[process-tracker] could not set job CPU priority");
198    }
199}
200
201/// One block's tracked tree, as [`AgentProcessRegistry::members_by_block`]
202/// reports it.
203#[derive(Debug, Clone)]
204pub struct BlockMembers {
205    pub block_id: String,
206    pub pids: Vec<u32>,
207    /// The PIDs assigned as the block's own process (the agent CLI or shell).
208    pub roots: HashSet<u32>,
209    pub confidence: TrackingConfidence,
210    pub cpu_time_ns: Option<u64>,
211}
212
213pub struct AgentProcessRegistry {
214    inner: Mutex<HashMap<String, RegistryEntry>>,
215    broker: Option<Arc<mps::Broker>>,
216    /// Live settings, read per spawn so a settings.json change applies to the
217    /// next agent spawn. `None` in tests and before bootstrap wires it.
218    config: Mutex<Option<Arc<crate::backend::wconfig::ConfigState>>>,
219}
220
221struct RegistryEntry {
222    tracker: Arc<dyn TrackerHandle>,
223    /// Last-known PID set from the most recent poll. Used to diff
224    /// against the current set so we only emit events for
225    /// additions/removals — not on every poll tick.
226    last_pids: HashSet<u32>,
227    /// Every PID handed to `assign` — the agent CLI itself, one per
228    /// (re)spawn. `agent_started` excludes them and their non-shell
229    /// children (MCP servers) from what we report.
230    roots: HashSet<u32>,
231}
232
233impl AgentProcessRegistry {
234    pub fn new(broker: Option<Arc<mps::Broker>>) -> Self {
235        Self {
236            inner: Mutex::new(HashMap::new()),
237            broker,
238            config: Mutex::new(None),
239        }
240    }
241
242    /// Attach the live settings (bootstrap).
243    pub fn set_config(&self, config: Arc<crate::backend::wconfig::ConfigState>) {
244        *self.config.lock() = Some(config);
245    }
246
247    /// The `agent:belownormalpriority` setting; `true` when unset or when no
248    /// settings are attached.
249    pub fn agent_below_normal_priority(&self) -> bool {
250        self.config
251            .lock()
252            .as_ref()
253            .and_then(|c| c.get_settings().extra.get(SETTING_AGENT_BELOW_NORMAL_PRIORITY).and_then(|v| v.as_bool()))
254            .unwrap_or(true)
255    }
256
257    /// Apply (or lift) the below-normal priority limit on `block_id`'s tree.
258    pub fn set_below_normal_priority(&self, block_id: &str, on: bool) -> Result<(), String> {
259        let tracker = self.ensure_tracker(block_id);
260        tracker.set_below_normal_priority(on)
261    }
262
263    /// Ensure a tracker exists for this block. Idempotent — calling
264    /// twice for the same block returns the existing tracker so the
265    /// job survives controller re-creation (e.g. on /clear).
266    pub fn ensure_tracker(&self, block_id: &str) -> Arc<dyn TrackerHandle> {
267        self.ensure_tracker_kind(block_id, false)
268    }
269
270    /// [`ensure_tracker`](Self::ensure_tracker) for an agent's block
271    /// (`agent`) or a terminal a person types in. The kind matters on every
272    /// Unix, where an agent gets a cgroup or the best-effort scan tracker (its
273    /// whole tree ends with it) and a terminal keeps Unix semantics: what the
274    /// user `nohup`s outlives it.
275    pub fn ensure_tracker_kind(&self, block_id: &str, agent: bool) -> Arc<dyn TrackerHandle> {
276        let mut map = self.inner.lock();
277        if let Some(entry) = map.get(block_id) {
278            return entry.tracker.clone();
279        }
280        let tracker = new_tracker(block_id, agent);
281        map.insert(
282            block_id.to_string(),
283            RegistryEntry {
284                tracker: tracker.clone(),
285                last_pids: HashSet::new(),
286                roots: HashSet::new(),
287            },
288        );
289        tracing::info!(
290            block_id = %block_id,
291            confidence = ?tracker.confidence(),
292            "[process-tracker] registered tracker"
293        );
294        tracker
295    }
296
297    /// Assign a freshly-spawned agent/shell PID to `block_id`'s tracker
298    /// (creating it if needed) and remember it as a root.
299    /// The tracker is an agent's or a terminal's (see
300    /// [`ensure_tracker_kind`](Self::ensure_tracker_kind)).
301    pub fn assign_kind(&self, block_id: &str, pid: u32, agent: bool) -> Result<(), String> {
302        let tracker = self.ensure_tracker_kind(block_id, agent);
303        tracker.assign_process(pid)?;
304        if let Some(entry) = self.inner.lock().get_mut(block_id) {
305            entry.roots.insert(pid);
306        }
307        Ok(())
308    }
309
310    /// Add `pid` to `block_id`'s tracker if it has one, without marking it a
311    /// root. `Ok(false)` when the block has no tracker.
312    pub fn adopt(&self, block_id: &str, pid: u32) -> Result<bool, String> {
313        let tracker = match self.inner.lock().get(block_id) {
314            Some(e) => e.tracker.clone(),
315            None => return Ok(false),
316        };
317        tracker.assign_process(pid)?;
318        Ok(true)
319    }
320
321    /// Drop a block's tracker — call when the pane closes. The tracker's
322    /// Drop impl kills the whole process tree (`KILL_ON_JOB_CLOSE` on
323    /// Windows, `cgroup.kill` on Linux; nothing on the stub).
324    pub fn remove(&self, block_id: &str) {
325        let mut map = self.inner.lock();
326        if map.remove(block_id).is_some() {
327            tracing::info!(block_id = %block_id, "[process-tracker] dropped tracker on pane close");
328        }
329    }
330
331    /// `(tracked block count, summed last-known PID count across all
332    /// blocks)` — diagnostic-only, for the periodic `mem_attribution` log
333    /// (`sysinfo.rs`) to correlate against commit/handle growth. A block
334    /// count that grows without bound (no matching `remove` on pane close)
335    /// or a PID count far exceeding what's actually open would both be
336    /// visible here well before the commit-side symptom is undeniable.
337    #[cfg(any(windows, test))]
338    pub fn stats(&self) -> (usize, usize) {
339        let map = self.inner.lock();
340        let pids: usize = map.values().map(|e| e.last_pids.len()).sum();
341        (map.len(), pids)
342    }
343
344    /// Processes the agent started through its tools (see
345    /// [`agent_started`]) — for the RPC endpoint, Swarm and the pane-close
346    /// confirmation. Excludes the CLI itself, conhost and MCP servers;
347    /// `kill_tree` still takes all of them.
348    pub fn list_block(&self, block_id: &str) -> Vec<TrackedProcess> {
349        let (tracker, roots) = match self.inner.lock().get(block_id) {
350            Some(e) => (e.tracker.clone(), e.roots.clone()),
351            None => return Vec::new(),
352        };
353        agent_started(tracker.list_members(), &roots)
354    }
355
356    /// Every tracked block's members for Tower: PIDs (plumbing included), the
357    /// roots, the tracker's confidence and the tree's CPU account. The trackers
358    /// are queried outside the registry lock.
359    pub fn members_by_block(&self) -> Vec<BlockMembers> {
360        let entries: Vec<(String, Arc<dyn TrackerHandle>, HashSet<u32>)> = self
361            .inner
362            .lock()
363            .iter()
364            .map(|(id, e)| (id.clone(), e.tracker.clone(), e.roots.clone()))
365            .collect();
366        entries
367            .into_iter()
368            .map(|(block_id, tracker, roots)| BlockMembers {
369                pids: tracker.member_pids(),
370                cpu_time_ns: tracker.cpu_time_ns(),
371                confidence: tracker.confidence(),
372                block_id,
373                roots,
374            })
375            .collect()
376    }
377
378    /// Confidence of a block's tracker — drives the "tracking is
379    /// best-effort on macOS" badge in the swarm UI.
380    pub fn confidence_of(&self, block_id: &str) -> TrackingConfidence {
381        self.inner
382            .lock()
383            .get(block_id)
384            .map(|e| e.tracker.confidence())
385            .unwrap_or(TrackingConfidence::None)
386    }
387
388    /// Ask everything in `block_id`'s tree to exit (SIGTERM on Linux), the
389    /// graceful step before [`remove`](Self::remove) kills what's left.
390    /// `false` when nothing was asked (no tracker, or no graceful signal).
391    pub fn terminate(&self, block_id: &str) -> bool {
392        let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
393        tracker.is_some_and(|t| t.terminate())
394    }
395
396    /// Every process in `block_id`'s tree, plumbing included; 0 without a
397    /// tracker.
398    pub fn member_count(&self, block_id: &str) -> usize {
399        let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
400        tracker.map_or(0, |t| t.member_count())
401    }
402
403    /// Kill the entire process tree for a given block. Returns `true`
404    /// if a tracker was found (the kill was dispatched). Does NOT
405    /// synchronously wait for descendants to actually exit — the
406    /// poller's next tick will pick up the state changes and emit
407    /// `agent:process-exited` events the frontend can react to.
408    pub fn kill_tree(&self, block_id: &str) -> bool {
409        let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
410        match tracker {
411            Some(t) => {
412                t.kill_tree();
413                true
414            }
415            None => false,
416        }
417    }
418
419    /// Kill a single PID if it's a member of the given block's tree.
420    /// Returns `true` if the tracker was found and the PID matched.
421    pub fn kill_pid(&self, block_id: &str, pid: u32) -> bool {
422        let tracker = self.inner.lock().get(block_id).map(|e| e.tracker.clone());
423        match tracker {
424            Some(t) => t.kill_pid(pid),
425            None => false,
426        }
427    }
428
429    /// Poll every tracked block's membership and diff against the
430    /// last-known set. Emits `agent:process-added` / `-exited` events
431    /// for each delta.
432    ///
433    /// Called by a background Tokio task on a ~2s interval.
434    pub fn poll_and_emit(&self) {
435        let mut map = self.inner.lock();
436        for (block_id, entry) in map.iter_mut() {
437            let current_members = agent_started(entry.tracker.list_members(), &entry.roots);
438            let current_pids: HashSet<u32> = current_members.iter().map(|p| p.pid).collect();
439
440            for added_pid in current_pids.difference(&entry.last_pids) {
441                if let Some(p) = current_members.iter().find(|p| p.pid == *added_pid) {
442                    if let Some(broker) = &self.broker {
443                        let added = crate::backend::mps_events::ProcessAddedEvent { block_id: block_id.clone(), process: p.clone() };
444                        broker.publish_event::<crate::backend::mps_events::AgentProcessAdded>(&added, vec![format!("block:{}", block_id)], 0);
445                    }
446                }
447            }
448            for removed_pid in entry.last_pids.difference(&current_pids) {
449                if let Some(broker) = &self.broker {
450                    let exited = crate::backend::mps_events::ProcessExitedEvent { block_id: block_id.clone(), pid: *removed_pid };
451                    broker.publish_event::<crate::backend::mps_events::AgentProcessExited>(&exited, vec![format!("block:{}", block_id)], 0);
452                }
453            }
454
455            entry.last_pids = current_pids;
456        }
457    }
458
459}
460
461/// Spawn the polling task. Drops when the registry's `Arc` refcount
462/// hits zero (host shutdown). ~2s cadence balances latency (new
463/// processes show up fast) with CPU overhead (job queries are cheap
464/// but not free).
465pub fn spawn_poller(registry: Arc<AgentProcessRegistry>) {
466    tokio::spawn(async move {
467        let mut interval = tokio::time::interval(std::time::Duration::from_secs(2));
468        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
469        loop {
470            interval.tick().await;
471            // Per-process OS queries for every tracked block plus a
472            // system-wide process snapshot, under two mutexes — never on an
473            // async worker (see `backend::blocking`). Awaited, so still one
474            // pass at a time.
475            let registry = Arc::clone(&registry);
476            if let Err(e) = tokio::task::spawn_blocking(move || registry.poll_and_emit()).await {
477                tracing::warn!(error = %e, "[process-tracker] poll task failed");
478            }
479        }
480    });
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486
487    #[test]
488    fn agent_cpu_priority_policy_defaults_on_and_follows_the_setting() {
489        let reg = AgentProcessRegistry::new(None);
490        // No settings attached (tests, early boot): on.
491        assert!(reg.agent_below_normal_priority());
492
493        let config = Arc::new(crate::backend::wconfig::ConfigState::with_config(
494            crate::backend::wconfig::build_default_config(),
495        ));
496        reg.set_config(Arc::clone(&config));
497        // Key absent: on.
498        assert!(reg.agent_below_normal_priority());
499
500        let mut settings = config.get_settings();
501        settings.extra.insert(SETTING_AGENT_BELOW_NORMAL_PRIORITY.to_string(), serde_json::json!(false));
502        config.update_settings(settings.clone());
503        assert!(!reg.agent_below_normal_priority(), "read live, not captured");
504
505        settings.extra.insert(SETTING_AGENT_BELOW_NORMAL_PRIORITY.to_string(), serde_json::json!(true));
506        config.update_settings(settings);
507        assert!(reg.agent_below_normal_priority());
508    }
509
510    #[test]
511    fn track_spawned_is_a_safe_no_op_without_a_global_registry() {
512        // `GLOBAL` is only ever set by `bootstrap/network.rs` at real host
513        // startup — never in this test binary — so this exercises the exact
514        // "tests silently skip tracker registration" behavior the doc comment
515        // on `GLOBAL` promises. Must not panic.
516        track_spawned("test-block-track-spawned-no-global", 999_999);
517    }
518
519    #[test]
520    fn stats_reflects_tracked_block_count_and_drops_on_remove() {
521        // A fresh, non-global registry (not `global()`) — this counts
522        // entries this test itself created, not whatever other tests
523        // running in parallel have registered against the process-wide
524        // `GLOBAL`. `stats()` backs the `mem_attribution` diagnostic log
525        // (sysinfo.rs) added while investigating a 2026-09-20 leak report.
526        let registry = AgentProcessRegistry::new(None);
527        assert_eq!(registry.stats(), (0, 0), "empty registry");
528
529        registry.ensure_tracker("block-a");
530        assert_eq!(registry.stats(), (1, 0), "one block, no poll yet so no known pids");
531
532        registry.ensure_tracker("block-b");
533        assert_eq!(registry.stats().0, 2, "two distinct blocks tracked");
534
535        // Re-registering the same block is idempotent (per ensure_tracker's
536        // own doc comment) — must not double-count.
537        registry.ensure_tracker("block-a");
538        assert_eq!(registry.stats().0, 2, "re-registering an existing block does not grow the count");
539
540        registry.remove("block-a");
541        assert_eq!(registry.stats().0, 1, "removed block no longer counted");
542    }
543
544    // This test used to be `#[ignore]`d as "environment-dependent", blaming
545    // the agent-shell harness's own job object. That was wrong: the Windows
546    // tracker opened the process with 0x0200 (PROCESS_SET_INFORMATION) where
547    // AssignProcessToJobObject needs PROCESS_SET_QUOTA (0x0100), so EVERY
548    // assignment failed with Access Denied — in the shipped app too (no
549    // production log ever shows "assigned process to job"). Nested jobs are
550    // fine; the access mask wasn't. Keep this test running so it can't
551    // silently break again. Windows-only: elsewhere `new_tracker` is still
552    // the membership-less stub, so there is nothing to observe (Codex P1 on
553    // #3425).
554    #[test]
555    #[cfg(windows)]
556    fn ensure_tracker_and_assign_process_track_a_real_short_lived_child() {
557        // Uses a fresh, non-global `AgentProcessRegistry` (not `track_spawned`'s
558        // `global()` path) so this doesn't touch the process-wide `GLOBAL`
559        // OnceLock other tests in this binary may rely on being unset.
560        //
561        // Spawns a real, disposable child (rather than assigning the test
562        // process's own PID) because `JobObjectTracker`'s Drop impl closes the
563        // job handle, which fires `KILL_ON_JOB_CLOSE` on Windows — assigning
564        // the test runner's own PID would kill the test process the moment
565        // the registry (and its tracker) drops at the end of this test.
566        // Alive for a few seconds, so it can't exit (and make the assignment
567        // race a dead process) before `assign_process` runs; killed below.
568        let mut child = if cfg!(windows) {
569            std::process::Command::new("cmd")
570                .args(["/C", "ping -n 2 127.0.0.1 > nul & ping -n 10 127.0.0.1 > nul"])
571                .spawn()
572        } else {
573            std::process::Command::new("sh")
574                .args(["-c", "sleep 10"])
575                .spawn()
576        }
577        .expect("failed to spawn a disposable test child");
578        let pid = child.id();
579
580        let registry = AgentProcessRegistry::new(None);
581        registry
582            .assign_kind("test-block-real-spawn", pid, false)
583            .expect("assign should succeed for a live child we just spawned");
584
585        let raw = registry.ensure_tracker("test-block-real-spawn").list_members();
586        assert!(
587            raw.iter().any(|p| p.pid == pid),
588            "expected pid {pid} to be a job member after assign"
589        );
590
591        // The root itself is plumbing, not agent-started; the `ping` its shell
592        // launches is. The second ping starts ~1s in, well after assignment,
593        // so it can't slip through the spawn/assign race.
594        let is_ping = |p: &TrackedProcess| p.command.to_ascii_lowercase().ends_with("ping.exe");
595        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
596        let started = loop {
597            let listed = registry.list_block("test-block-real-spawn");
598            if listed.iter().any(is_ping) || std::time::Instant::now() > deadline {
599                break listed;
600            }
601            std::thread::sleep(std::time::Duration::from_millis(100));
602        };
603        assert!(
604            !started.iter().any(|p| p.pid == pid),
605            "the assigned root must not be listed as agent-started"
606        );
607        assert!(
608            started.iter().any(|p| is_ping(p) && p.parent_pid == Some(pid)),
609            "expected the shell's ping child in list_block, got {started:?}"
610        );
611
612        // Reap before `registry` drops, so KILL_ON_JOB_CLOSE has nothing left
613        // to terminate.
614        let _ = child.kill();
615        let _ = child.wait();
616    }
617
618    #[test]
619    fn adopt_never_creates_a_tracker() {
620        let registry = AgentProcessRegistry::new(None);
621        assert_eq!(registry.adopt("no-such-block", std::process::id()), Ok(false));
622        assert_eq!(registry.stats().0, 0, "adopt must not register a tracker");
623    }
624
625    /// A `Shell()` process adopted into an agent's tracker is listed as
626    /// agent-started and dies when the agent's tracker is released
627    /// (agent_teardown spec §6.5).
628    #[test]
629    #[cfg(windows)]
630    fn an_adopted_process_is_listed_and_dies_with_the_agent() {
631        let spawn = || {
632            std::process::Command::new("cmd")
633                .args(["/C", "ping -n 30 127.0.0.1 > nul"])
634                .spawn()
635                .expect("failed to spawn a disposable test child")
636        };
637        let (mut agent, mut shell) = (spawn(), spawn());
638        let registry = AgentProcessRegistry::new(None);
639        registry.assign_kind("test-block-adopt", agent.id(), false).expect("assign the agent root");
640        assert_eq!(registry.adopt("test-block-adopt", shell.id()), Ok(true));
641
642        let listed = registry.list_block("test-block-adopt");
643        assert!(
644            listed.iter().any(|p| p.pid == shell.id()),
645            "an adopted shell is agent-started, not a root: {listed:?}"
646        );
647
648        registry.remove("test-block-adopt");
649        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
650        while shell.try_wait().ok().flatten().is_none() && std::time::Instant::now() < deadline {
651            std::thread::sleep(std::time::Duration::from_millis(50));
652        }
653        assert!(shell.try_wait().ok().flatten().is_some(), "releasing the agent's tracker must end the adopted shell");
654        let _ = agent.kill();
655        let _ = agent.wait();
656    }
657
658    /// `spawn_tracked`'s Windows path: a child started suspended is in the
659    /// job before it runs, and runs once resumed (it never stays stuck).
660    #[tokio::test]
661    #[cfg(windows)]
662    async fn a_suspended_child_is_assigned_then_resumed_and_runs() {
663        use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
664        let tracker = super::super::windows::JobObjectTracker::new("test-suspended").unwrap();
665        let mut cmd = tokio::process::Command::new("cmd");
666        cmd.args(["/C", "exit 7"]).creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
667        let mut child = cmd.spawn().expect("spawn suspended");
668        let pid = child.id().unwrap();
669        tracker.assign_process(pid).unwrap();
670        assert!(tracker.list_members().iter().any(|p| p.pid == pid), "in the job before it runs");
671        agentmux_common::win32::resume_main_thread(pid).unwrap();
672        let status = tokio::time::timeout(std::time::Duration::from_secs(10), child.wait())
673            .await
674            .expect("the resumed child ran and exited")
675            .unwrap();
676        assert_eq!(status.code(), Some(7));
677    }
678
679    /// The job's CPU account grows while a member works and keeps that time
680    /// after the member exits, which a per-process sum would lose.
681    #[test]
682    #[cfg(windows)]
683    fn a_jobs_cpu_time_keeps_what_exited_members_used() {
684        let tracker = super::super::windows::JobObjectTracker::new("test-job-cpu").unwrap();
685        assert_eq!(tracker.cpu_time_ns(), Some(0), "an empty job has used nothing");
686        // A busy child: cmd counting to two million (a second or more of CPU),
687        // then exiting.
688        let mut child = std::process::Command::new("cmd")
689            .args(["/C", "for /L %i in (1,1,2000000) do @rem"])
690            .spawn()
691            .expect("spawn cmd");
692        tracker.assign_process(child.id()).unwrap();
693        let _ = child.wait();
694        let used = tracker.cpu_time_ns().unwrap();
695        assert!(used >= 300_000_000, "the exited member's CPU time is kept: {used} ns");
696        assert!(tracker.member_pids().is_empty(), "nothing is left running");
697    }
698
699    /// After `kill_tree` the job stays usable: a respawned process is still
700    /// assigned and listed.
701    #[test]
702    #[cfg(windows)]
703    fn a_job_still_tracks_after_kill_tree() {
704        let spawn = || {
705            std::process::Command::new("cmd")
706                .args(["/C", "ping -n 30 127.0.0.1 > nul"])
707                .spawn()
708                .expect("failed to spawn a disposable test child")
709        };
710        let tracker = super::super::windows::JobObjectTracker::new("test-kill-keeps-job").unwrap();
711        let mut first = spawn();
712        tracker.assign_process(first.id()).unwrap();
713        tracker.kill_tree();
714        let _ = first.wait();
715        let mut second = spawn();
716        tracker.assign_process(second.id()).expect("the job still accepts processes");
717        assert!(tracker.list_members().iter().any(|p| p.pid == second.id()));
718        tracker.kill_tree();
719        let _ = second.wait();
720    }
721}