Skip to main content

agent_top_core/
collector.rs

1//! Joins the process table with the transcripts into a `Snapshot`.
2//!
3//! The collector knows no harness by name. Each one is a `HarnessAdapter`
4//! (RFC-101): it lists its transcripts, says which belong to which process,
5//! and opens a tracker for one. The collector walks the process forest, asks
6//! the adapter for each root, and builds the rows.
7
8use crate::harness::{self, AttributeContext, HarnessAdapter, McpUsage, RegistryHints, SessionSummary, SessionTracker, SpanRetention};
9use crate::model::*;
10use crate::process::{ProcessScanner, RawProc, build_forest};
11use std::collections::{BTreeMap, HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15#[derive(Debug, Clone)]
16pub struct CollectorOptions {
17    /// How long after its last write a process-less transcript still shows as `stopped`.
18    pub stopped_window: Duration,
19    /// How often to re-list transcript directories.
20    pub fs_scan_interval: Duration,
21    /// A transcript idle for longer than this counts as idle even if the
22    /// harness never wrote an end-of-turn marker.
23    pub activity_timeout: Duration,
24}
25
26impl Default for CollectorOptions {
27    fn default() -> Self {
28        CollectorOptions {
29            stopped_window: Duration::from_secs(30 * 60),
30            fs_scan_interval: Duration::from_secs(5),
31            activity_timeout: Duration::from_secs(15 * 60),
32        }
33    }
34}
35
36pub struct Collector {
37    opts: CollectorOptions,
38    scanner: ProcessScanner,
39    adapters: Vec<Box<dyn HarnessAdapter>>,
40    trackers: HashMap<PathBuf, Box<dyn SessionTracker>>,
41    last_fs_scan: Option<Instant>,
42    /// What is known about every MCP process seen this run, by pid, so that
43    /// an orphan can say which agent it used to belong to. See `OrphanOrigin`.
44    mcp_memory: HashMap<u32, McpMemory>,
45}
46
47/// One MCP process's history for the run.
48#[derive(Debug, Clone)]
49struct McpMemory {
50    /// The process start time, so a reused pid is not mistaken for the same process.
51    start_time: u64,
52    first_seen: SystemTime,
53    parent: Option<OrphanParent>,
54    orphaned_at: Option<SystemTime>,
55}
56
57impl Collector {
58    pub fn new(opts: CollectorOptions) -> Self {
59        Collector {
60            opts,
61            scanner: ProcessScanner::new(),
62            adapters: harness::adapters(),
63            trackers: HashMap::new(),
64            last_fs_scan: None,
65            mcp_memory: HashMap::new(),
66        }
67    }
68
69    fn rescan_fs_if_due(&mut self) {
70        let due = self.last_fs_scan.map(|t| t.elapsed() >= self.opts.fs_scan_interval).unwrap_or(true);
71        if !due {
72            return;
73        }
74        self.last_fs_scan = Some(Instant::now());
75        let since = SystemTime::now().checked_sub(self.opts.stopped_window).unwrap_or(UNIX_EPOCH);
76        for a in &mut self.adapters {
77            a.rescan(since);
78        }
79    }
80
81    fn adapter(&self, harness: Harness) -> Option<&dyn HarnessAdapter> {
82        self.adapters.iter().find(|a| a.harness() == harness).map(|a| a.as_ref())
83    }
84
85    /// The tracker for a transcript, opened on first sight.
86    fn tracker_for(&mut self, path: &Path, harness: Harness) -> Option<&mut Box<dyn SessionTracker>> {
87        if !self.trackers.contains_key(path) {
88            let tracker = self.adapter(harness)?.open(path, SpanRetention::Recent);
89            self.trackers.insert(path.to_path_buf(), tracker);
90        }
91        self.trackers.get_mut(path)
92    }
93
94    pub fn collect(&mut self) -> Snapshot {
95        self.scanner.refresh();
96        self.rescan_fs_if_due();
97        let host = self.scanner.host();
98        let procs = self.scanner.processes();
99        let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
100        let (roots, orphans) = build_forest(&procs);
101
102        // Each adapter sees all of its processes before any is attributed.
103        for a in &mut self.adapters {
104            let mine: Vec<&ProcNode> = roots.iter().filter(|r| r.harness == Some(a.harness())).collect();
105            a.prepare(&mine);
106        }
107
108        let now = SystemTime::now();
109        let mut agents = Vec::new();
110        let mut attached: HashSet<PathBuf> = HashSet::new();
111
112        for root in roots {
113            let raw = by_pid.get(&root.pid).copied();
114            let harness = root.harness.unwrap_or(Harness::Unknown);
115            let proc_start = raw.map(|p| UNIX_EPOCH + Duration::from_secs(p.start_time)).unwrap_or(now);
116            let hints = self.adapter(harness).and_then(|a| a.hints(root.pid));
117            let cwd = root.cwd.clone().or_else(|| hints.as_ref().and_then(|h| h.cwd.clone()));
118
119            // One process can host several conversations. Claude Code runs one
120            // per process; the Codex app-server runs many.
121            let (paths, attribution) = match self.adapter(harness) {
122                Some(a) => {
123                    let ctx = AttributeContext {
124                        cwd: cwd.as_deref(),
125                        proc_start,
126                        now,
127                        attached: &attached,
128                        activity_timeout: self.opts.activity_timeout,
129                    };
130                    a.attribute(&root, raw, &ctx)
131                }
132                None => (Vec::new(), Attribution::None),
133            };
134
135            let (cpu, rss, count, mcp) = root.totals();
136            let hints = hints.unwrap_or_default();
137
138            // No transcript: the process still deserves a row.
139            if paths.is_empty() {
140                let summary = SessionSummary::default();
141                let state = live_state(&hints, summary.activity, None, cpu, &self.opts);
142                agents.push(Agent {
143                    id: format!("pid:{}", root.pid),
144                    name: hints.name.clone().unwrap_or_else(|| display_name(harness, cwd.as_deref())),
145                    harness,
146                    state,
147                    activity: summary.activity,
148                    pid: Some(root.pid),
149                    session_id: hints.session_id.clone(),
150                    session_path: None,
151                    cwd,
152                    model: None,
153                    harness_version: hints.version.clone(),
154                    usage: summary.usage,
155                    cost_usd: 0.0,
156                    cost_breakdown: Default::default(),
157                    price_source: None,
158                    unpriced_tokens: 0,
159                    turns: 0,
160                    subagent_turns: 0,
161                    tool_calls: 0,
162                    web_searches: 0,
163                    spans: Vec::new(),
164                    age_secs: root.age_secs,
165                    idle_secs: None,
166                    cpu_percent: cpu,
167                    rss_bytes: rss,
168                    process_count: count,
169                    mcp_count: mcp,
170                    mcp_servers: mcp_rows(Some(&root), &BTreeMap::new()),
171                    tree: Some(root),
172                    attribution,
173                    shares_process: false,
174                    parse_warning: None,
175                    rate_limit: None,
176                });
177                continue;
178            }
179
180            for (i, path) in paths.iter().enumerate() {
181                // Only the first row carries the process, so that a machine's
182                // totals are not multiplied by the number of conversations.
183                let owns_process = i == 0;
184                let Some(tr) = self.tracker_for(path, harness) else { continue };
185                let _ = tr.refresh();
186                let mut summary = tr.summary().clone();
187                attached.insert(path.clone());
188
189                if summary.session_id.is_none() {
190                    summary.session_id = hints.session_id.clone();
191                }
192                if summary.harness_version.is_none() {
193                    summary.harness_version = hints.version.clone();
194                }
195
196                let idle_secs = summary.last_activity.and_then(|t| now.duration_since(t).ok()).map(|d| d.as_secs());
197                let state = live_state(&hints, summary.activity, idle_secs, cpu, &self.opts);
198                // A thread names itself after its own working directory, which
199                // is the only thing distinguishing two rows on one app-server.
200                let name = match (hints.name.clone(), paths.len()) {
201                    (Some(n), 1) => n,
202                    _ => display_name(harness, summary.cwd.as_deref().or(cwd.as_deref())),
203                };
204                let id = match summary.session_id.as_deref() {
205                    Some(sid) => format!("pid:{}:{}", root.pid, sid),
206                    None => format!("pid:{}:{}", root.pid, path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default()),
207                };
208
209                agents.push(Agent {
210                    id,
211                    name,
212                    harness,
213                    state,
214                    activity: summary.activity,
215                    pid: Some(root.pid),
216                    session_id: summary.session_id.clone(),
217                    session_path: Some(path.clone()),
218                    cwd: summary.cwd.clone().or_else(|| cwd.clone()),
219                    model: summary.model.clone(),
220                    harness_version: summary.harness_version.clone(),
221                    usage: summary.usage,
222                    cost_usd: summary.cost_usd,
223                    cost_breakdown: summary.cost_breakdown,
224                    price_source: summary.model.as_deref().and_then(|m| crate::pricing::table().source_for(m)),
225                    unpriced_tokens: summary.unpriced_tokens,
226                    turns: summary.turns,
227                    subagent_turns: summary.subagent_turns,
228                    tool_calls: summary.tool_calls,
229                    web_searches: summary.web_searches,
230                    spans: summary.spans.to_vec(),
231                    age_secs: root.age_secs,
232                    idle_secs,
233                    cpu_percent: if owns_process { cpu } else { 0.0 },
234                    rss_bytes: if owns_process { rss } else { 0 },
235                    process_count: if owns_process { count } else { 0 },
236                    mcp_count: if owns_process { mcp } else { 0 },
237                    mcp_servers: mcp_rows(if owns_process { Some(&root) } else { None }, &summary.mcp),
238                    tree: if owns_process { Some(root.clone()) } else { None },
239                    attribution,
240                    shares_process: !owns_process,
241                    parse_warning: parse_warning(&summary, harness),
242                    rate_limit: summary.rate_limit.clone(),
243                });
244            }
245        }
246
247        // Stopped agents: recently written transcripts nobody owns.
248        let stopped: Vec<(PathBuf, Harness)> =
249            self.adapters.iter().flat_map(|a| a.unowned(&attached).into_iter().map(move |p| (p, a.harness()))).collect();
250        for (p, harness) in stopped {
251            let Some(tr) = self.tracker_for(&p, harness) else { continue };
252            let _ = tr.refresh();
253            let s = tr.summary().clone();
254            if s.turns == 0 && s.usage.total() == 0 {
255                continue;
256            }
257            let idle_secs = s.last_activity.and_then(|t| now.duration_since(t).ok()).map(|d| d.as_secs());
258            let id = s.session_id.clone().unwrap_or_else(|| p.file_stem().map(|x| x.to_string_lossy().into_owned()).unwrap_or_default());
259            agents.push(Agent {
260                id: format!("session:{id}"),
261                name: display_name(harness, s.cwd.as_deref()),
262                harness,
263                state: AgentState::Stopped,
264                activity: s.activity,
265                pid: None,
266                session_id: Some(id),
267                session_path: Some(p),
268                cwd: s.cwd.clone(),
269                model: s.model.clone(),
270                harness_version: s.harness_version.clone(),
271                usage: s.usage,
272                cost_usd: s.cost_usd,
273                cost_breakdown: s.cost_breakdown,
274                price_source: s.model.as_deref().and_then(|m| crate::pricing::table().source_for(m)),
275                unpriced_tokens: s.unpriced_tokens,
276                turns: s.turns,
277                subagent_turns: s.subagent_turns,
278                tool_calls: s.tool_calls,
279                web_searches: s.web_searches,
280                spans: s.spans.to_vec(),
281                age_secs: idle_secs.unwrap_or(0),
282                idle_secs,
283                cpu_percent: 0.0,
284                rss_bytes: 0,
285                process_count: 0,
286                mcp_count: 0,
287                mcp_servers: mcp_rows(None, &s.mcp),
288                tree: None,
289                attribution: Attribution::TranscriptOnly,
290                shares_process: false,
291                parse_warning: parse_warning(&s, harness),
292                rate_limit: s.rate_limit.clone(),
293            });
294        }
295
296        // Drop trackers for transcripts that fell out of the window.
297        let keep: HashSet<&PathBuf> = agents.iter().filter_map(|a| a.session_path.as_ref()).collect();
298        self.trackers.retain(|p, _| keep.contains(p));
299
300        let orphan_origins = self.remember_mcp(&agents, &orphans, &by_pid, now);
301
302        let mut snap = Snapshot {
303            schema_version: SNAPSHOT_SCHEMA_VERSION,
304            taken_at: now,
305            host,
306            agents,
307            orphans,
308            orphan_origins,
309            totals: Totals::default(),
310        };
311        snap.compute_totals();
312        snap
313    }
314
315    /// Note which agent each MCP process is under this tick, and which of the
316    /// orphans used to be under one. A process seen under an agent at one
317    /// tick and among the orphans at the next is reported as orphaned from
318    /// that agent; one that was already an orphan when the run started has
319    /// no parent on record. Memory is per run and per process start time, so
320    /// a reused pid starts over.
321    fn remember_mcp(
322        &mut self,
323        agents: &[Agent],
324        orphans: &[ProcNode],
325        by_pid: &HashMap<u32, &RawProc>,
326        now: SystemTime,
327    ) -> Vec<OrphanOrigin> {
328        let start_of = |pid: u32| by_pid.get(&pid).map(|p| p.start_time).unwrap_or(0);
329        for a in agents {
330            let Some(tree) = &a.tree else { continue };
331            let parent = OrphanParent { pid: tree.pid, agent_id: a.id.clone(), name: a.name.clone() };
332            let under: Vec<u32> = tree.mcp_roots().iter().map(|n| n.pid).collect();
333            for pid in under {
334                let m = touch(&mut self.mcp_memory, pid, start_of(pid), now);
335                m.parent = Some(parent.clone());
336                m.orphaned_at = None;
337            }
338        }
339        let mut origins = Vec::with_capacity(orphans.len());
340        for o in orphans {
341            let m = touch(&mut self.mcp_memory, o.pid, start_of(o.pid), now);
342            if m.parent.is_some() && m.orphaned_at.is_none() {
343                m.orphaned_at = Some(now);
344            }
345            origins.push(OrphanOrigin { pid: o.pid, first_seen: m.first_seen, orphaned_at: m.orphaned_at, parent: m.parent.clone() });
346        }
347        // A process that has exited is forgotten, so the map does not grow
348        // with every server ever started.
349        self.mcp_memory.retain(|pid, _| by_pid.contains_key(pid));
350        origins
351    }
352}
353
354/// The memory entry for a process, fresh if the pid is new or has been
355/// reused by a process with a different start time.
356fn touch(memory: &mut HashMap<u32, McpMemory>, pid: u32, start_time: u64, now: SystemTime) -> &mut McpMemory {
357    let entry = memory.entry(pid).or_insert(McpMemory { start_time, first_seen: now, parent: None, orphaned_at: None });
358    if entry.start_time != start_time {
359        *entry = McpMemory { start_time, first_seen: now, parent: None, orphaned_at: None };
360    }
361    entry
362}
363
364/// One row per MCP server, from the processes under the agent and the servers
365/// its transcript names, joined where they can be.
366///
367/// The transcript knows a server by the name the harness configured
368/// (`filesystem`, `chrome-devtools`); the process table knows a command line
369/// (`npx -y @modelcontextprotocol/server-filesystem /tmp`). Neither side
370/// carries the other's key, so the join is a name test: a server whose
371/// normalised name appears in a process's normalised command line is that
372/// process. When exactly one process and one server are left over, they are
373/// taken to be the same, and the row says so. Anything else stays a row of
374/// its own: a process the agent has not called yet, or a server with no
375/// process, which is an HTTP server or one that has exited.
376pub fn mcp_rows(tree: Option<&ProcNode>, usage: &BTreeMap<String, McpUsage>) -> Vec<McpServer> {
377    let procs: Vec<&ProcNode> = tree.map(|t| t.mcp_roots()).unwrap_or_default();
378    let mut rows = Vec::new();
379    let mut unmatched_procs: Vec<&ProcNode> = Vec::new();
380    let mut unmatched_servers: Vec<(&String, &McpUsage)> = Vec::new();
381    let mut claimed: HashSet<u32> = HashSet::new();
382
383    for (name, u) in usage {
384        let key = normalise(name);
385        let hit = procs.iter().find(|p| !claimed.contains(&p.pid) && !key.is_empty() && subtree_mentions(p, &key));
386        match hit {
387            Some(p) => {
388                claimed.insert(p.pid);
389                rows.push(row(name.clone(), Some(p), Some(u), McpMatch::Name));
390            }
391            None => unmatched_servers.push((name, u)),
392        }
393    }
394    for p in &procs {
395        if !claimed.contains(&p.pid) {
396            unmatched_procs.push(p);
397        }
398    }
399    if let ([p], [(name, u)]) = (unmatched_procs.as_slice(), unmatched_servers.as_slice()) {
400        rows.push(row((*name).clone(), Some(p), Some(u), McpMatch::Sole));
401        return rows;
402    }
403    for (name, u) in unmatched_servers {
404        rows.push(row(name.clone(), None, Some(u), McpMatch::TranscriptOnly));
405    }
406    for p in unmatched_procs {
407        rows.push(row(p.name.clone(), Some(p), None, McpMatch::ProcessOnly));
408    }
409    rows
410}
411
412/// Whether the server's normalised name appears in the command line of the
413/// process or of anything under it: `npx` names the package, its `node`
414/// child names the binary, and the configured name may match either.
415fn subtree_mentions(p: &ProcNode, key: &str) -> bool {
416    let mut found = false;
417    p.walk(0, &mut |n, _| found |= normalise(&n.cmdline).contains(key));
418    found
419}
420
421fn row(name: String, p: Option<&ProcNode>, u: Option<&McpUsage>, matched_by: McpMatch) -> McpServer {
422    let u = u.copied().unwrap_or_default();
423    // CPU and memory are the server's whole subtree, as the process count is.
424    let totals = p.map(|p| p.totals());
425    McpServer {
426        name,
427        pid: p.map(|p| p.pid),
428        cmdline: p.map(|p| p.cmdline.clone()),
429        cpu_percent: totals.map(|t| t.0).unwrap_or(0.0),
430        rss_bytes: totals.map(|t| t.1).unwrap_or(0),
431        age_secs: p.map(|p| p.age_secs),
432        calls: u.calls,
433        errors: u.errors,
434        last_call: u.last_call,
435        matched_by,
436    }
437}
438
439/// Lowercase, letters and digits only, so `chrome-devtools` finds
440/// `chrome-devtools-mcp` and `server_filesystem` finds `server-filesystem`.
441fn normalise(s: &str) -> String {
442    s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
443}
444
445/// A transcript that parsed while its usage records did not is a format change,
446/// not a quiet session. Naming the harness version makes the report actionable:
447/// it is the first thing anyone will ask for.
448fn parse_warning(s: &SessionSummary, harness: Harness) -> Option<String> {
449    if !s.health.fields_unrecognised() {
450        return None;
451    }
452    let version = s.harness_version.as_deref().unwrap_or("unknown version");
453    Some(format!(
454        "usage fields not recognised in {} {}: tokens and cost are unreliable, agent-top may need updating",
455        harness.label(),
456        version
457    ))
458}
459
460fn live_state(hints: &RegistryHints, activity: Activity, idle_secs: Option<u64>, cpu: f32, opts: &CollectorOptions) -> AgentState {
461    // Statuses observed in the registry so far (Claude Code 2.1.259): "busy",
462    // "idle", "shell". Unknown values fall through to the transcript heuristic.
463    match hints.status.as_deref() {
464        Some("busy" | "running" | "working" | "shell" | "tool" | "thinking") => return AgentState::Running,
465        Some("idle" | "waiting" | "paused" | "permission" | "blocked") => return AgentState::Idle,
466        _ => {}
467    }
468    match activity {
469        Activity::Working => {
470            if idle_secs.map(|s| s > opts.activity_timeout.as_secs()).unwrap_or(false) {
471                AgentState::Idle
472            } else {
473                AgentState::Running
474            }
475        }
476        Activity::Waiting => AgentState::Idle,
477        Activity::Unknown => {
478            if cpu > 5.0 || idle_secs.map(|s| s < 10).unwrap_or(false) {
479                AgentState::Running
480            } else {
481                AgentState::Idle
482            }
483        }
484    }
485}
486
487fn display_name(harness: Harness, cwd: Option<&Path>) -> String {
488    match cwd.and_then(|c| c.file_name()).map(|f| f.to_string_lossy().into_owned()) {
489        Some(dir) => format!("{}:{}", harness.label(), dir),
490        None => harness.label().to_string(),
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn the_registry_status_beats_the_transcript_heuristic() {
500        let opts = CollectorOptions::default();
501        let busy = RegistryHints { status: Some("busy".into()), ..Default::default() };
502        assert_eq!(live_state(&busy, Activity::Waiting, Some(0), 0.0, &opts), AgentState::Running);
503        let idle = RegistryHints { status: Some("idle".into()), ..Default::default() };
504        assert_eq!(live_state(&idle, Activity::Working, Some(0), 90.0, &opts), AgentState::Idle);
505        // No registry, which is every harness but Claude Code: the transcript decides.
506        let none = RegistryHints::default();
507        assert_eq!(live_state(&none, Activity::Working, Some(1), 0.0, &opts), AgentState::Running);
508        assert_eq!(live_state(&none, Activity::Working, Some(opts.activity_timeout.as_secs() + 1), 0.0, &opts), AgentState::Idle);
509        assert_eq!(live_state(&none, Activity::Waiting, Some(1), 90.0, &opts), AgentState::Idle);
510        assert_eq!(live_state(&none, Activity::Unknown, Some(3), 0.0, &opts), AgentState::Running);
511        assert_eq!(live_state(&none, Activity::Unknown, Some(300), 0.0, &opts), AgentState::Idle);
512    }
513
514    fn node(pid: u32, kind: ProcKind, cmd: &str) -> ProcNode {
515        ProcNode {
516            pid,
517            ppid: Some(1),
518            name: cmd.split(' ').next().unwrap().to_string(),
519            cmdline: cmd.to_string(),
520            kind,
521            harness: None,
522            cpu_percent: 0.5,
523            rss_bytes: 10 << 20,
524            age_secs: 60,
525            cwd: None,
526            children: Vec::new(),
527        }
528    }
529
530    fn used(calls: u64) -> McpUsage {
531        McpUsage { calls, errors: 0, last_call: Some(UNIX_EPOCH) }
532    }
533
534    #[test]
535    fn mcp_rows_join_processes_to_servers_by_name_then_by_elimination() {
536        let mut root = node(10, ProcKind::Agent, "claude");
537        root.children = vec![
538            node(11, ProcKind::Mcp, "npx -y @modelcontextprotocol/server-filesystem /tmp"),
539            node(12, ProcKind::Mcp, "node /opt/chrome-devtools-mcp/build/index.js"),
540            node(13, ProcKind::Shell, "zsh -c cargo test"),
541        ];
542        let mut usage = BTreeMap::new();
543        usage.insert("chrome-devtools".to_string(), used(3));
544        usage.insert("filesystem".to_string(), used(7));
545        usage.insert("linear".to_string(), used(1));
546        let rows = mcp_rows(Some(&root), &usage);
547        assert_eq!(rows.len(), 3);
548        let by_name: HashMap<&str, &McpServer> = rows.iter().map(|r| (r.name.as_str(), r)).collect();
549        assert_eq!(by_name["filesystem"].pid, Some(11));
550        assert_eq!(by_name["filesystem"].calls, 7);
551        assert_eq!(by_name["filesystem"].matched_by, McpMatch::Name);
552        assert_eq!(by_name["chrome-devtools"].pid, Some(12));
553        // An HTTP server, or one that exited: called, but no process.
554        assert_eq!(by_name["linear"].pid, None);
555        assert_eq!(by_name["linear"].matched_by, McpMatch::TranscriptOnly);
556
557        // One process whose command never mentions its configured name, and
558        // one server: taken to be the same, and labelled as a guess.
559        root.children = vec![node(14, ProcKind::Mcp, "uvx some-tool serve --stdio")];
560        let mut usage = BTreeMap::new();
561        usage.insert("tickets".to_string(), used(2));
562        let rows = mcp_rows(Some(&root), &usage);
563        assert_eq!(rows.len(), 1);
564        assert_eq!((rows[0].name.as_str(), rows[0].pid, rows[0].calls, rows[0].matched_by), ("tickets", Some(14), 2, McpMatch::Sole));
565
566        // An npx wrapper and its node child are one server, found through
567        // the child's command line, with the wrapper's pid.
568        let mut wrapper = node(16, ProcKind::Mcp, "npm exec @modelcontextprotocol/server-filesystem /tmp");
569        wrapper.children = vec![node(17, ProcKind::Mcp, "node /x/.bin/mcp-server-filesystem /tmp")];
570        root.children = vec![wrapper];
571        let mut usage = BTreeMap::new();
572        usage.insert("filesystem".to_string(), used(4));
573        let rows = mcp_rows(Some(&root), &usage);
574        assert_eq!(rows.len(), 1);
575        assert_eq!((rows[0].pid, rows[0].matched_by), (Some(16), McpMatch::Name));
576        assert_eq!(rows[0].rss_bytes, 20 << 20, "the subtree's memory");
577        assert_eq!(root.totals().3, 1, "one server, two processes");
578
579        // Two such processes: no guessing, each stays its own row.
580        root.children = vec![node(14, ProcKind::Mcp, "uvx some-tool serve --stdio")];
581        root.children.push(node(15, ProcKind::Mcp, "uvx other-tool serve"));
582        let rows = mcp_rows(Some(&root), &usage);
583        assert_eq!(rows.len(), 3);
584        assert!(rows.iter().filter(|r| r.matched_by == McpMatch::ProcessOnly).count() == 2);
585        assert!(mcp_rows(None, &BTreeMap::new()).is_empty());
586    }
587
588    fn raw(pid: u32, start_time: u64) -> RawProc {
589        RawProc {
590            pid,
591            ppid: Some(1),
592            name: "x".into(),
593            exe: None,
594            cmd: vec!["x".into()],
595            cwd: None,
596            cpu_percent: 0.0,
597            rss_bytes: 0,
598            start_time,
599            run_time: 1,
600        }
601    }
602
603    /// The RFC-104 success test, in miniature: a server seen under an agent
604    /// at one tick and among the orphans at the next says which agent it
605    /// came from; one that was an orphan from the start does not pretend to.
606    #[test]
607    fn an_orphan_remembers_the_agent_it_was_under() {
608        let mut c = Collector::new(CollectorOptions::default());
609        let t0 = UNIX_EPOCH + Duration::from_secs(1_000);
610        let t1 = t0 + Duration::from_secs(10);
611        let mut root = node(10, ProcKind::Agent, "claude");
612        root.children = vec![node(11, ProcKind::Mcp, "npx server-filesystem")];
613        let mut agent = Agent {
614            id: "pid:10".into(),
615            name: "claude:proj".into(),
616            harness: Harness::Claude,
617            state: AgentState::Running,
618            activity: Activity::Working,
619            pid: Some(10),
620            session_id: None,
621            session_path: None,
622            cwd: None,
623            model: None,
624            harness_version: None,
625            usage: TokenUsage::default(),
626            cost_usd: 0.0,
627            cost_breakdown: Default::default(),
628            price_source: None,
629            unpriced_tokens: 0,
630            turns: 0,
631            subagent_turns: 0,
632            tool_calls: 0,
633            web_searches: 0,
634            spans: Vec::new(),
635            age_secs: 0,
636            idle_secs: None,
637            cpu_percent: 0.0,
638            rss_bytes: 0,
639            process_count: 2,
640            mcp_count: 1,
641            mcp_servers: Vec::new(),
642            tree: Some(root),
643            attribution: Attribution::HarnessRegistry,
644            shares_process: false,
645            parse_warning: None,
646            rate_limit: None,
647        };
648        let procs = [raw(10, 5), raw(11, 6), raw(99, 7)];
649        let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
650
651        // Tick 0: the server is under its agent; 99 is an orphan from the start.
652        let origins = c.remember_mcp(std::slice::from_ref(&agent), &[node(99, ProcKind::Mcp, "uvx mcp-server-git")], &by_pid, t0);
653        assert_eq!(origins.len(), 1);
654        assert_eq!(origins[0].pid, 99);
655        assert!(origins[0].parent.is_none());
656        assert_eq!(origins[0].first_seen, t0);
657
658        // Tick 1: the agent is gone and the server is an orphan.
659        agent.tree = None;
660        let procs = [raw(11, 6), raw(99, 7)];
661        let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
662        let orphans = [node(11, ProcKind::Mcp, "npx server-filesystem"), node(99, ProcKind::Mcp, "uvx mcp-server-git")];
663        let origins = c.remember_mcp(&[], &orphans, &by_pid, t1);
664        let fs = origins.iter().find(|o| o.pid == 11).unwrap();
665        assert_eq!(fs.parent.as_ref().map(|p| (p.pid, p.name.as_str())), Some((10, "claude:proj")));
666        assert_eq!(fs.orphaned_at, Some(t1));
667        assert_eq!(fs.first_seen, t0);
668        let git = origins.iter().find(|o| o.pid == 99).unwrap();
669        assert!(git.parent.is_none() && git.orphaned_at.is_none());
670
671        // Tick 2: pid 11 is reused by a different process. The memory starts over.
672        let procs = [raw(11, 900)];
673        let by_pid: HashMap<u32, &RawProc> = procs.iter().map(|p| (p.pid, p)).collect();
674        let origins = c.remember_mcp(&[], &orphans[..1], &by_pid, t1 + Duration::from_secs(10));
675        assert!(origins[0].parent.is_none());
676        assert!(!c.mcp_memory.contains_key(&99), "an exited process is forgotten");
677    }
678
679    #[test]
680    fn every_adapter_is_a_distinct_harness_and_recognises_its_own_fixture() {
681        let adapters = harness::adapters();
682        let mut seen = HashSet::new();
683        for a in &adapters {
684            assert!(seen.insert(a.harness()), "two adapters for {:?}", a.harness());
685        }
686        let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
687        for (file, want) in
688            [("claude-2.1.226.jsonl", Harness::Claude), ("codex-0.130.jsonl", Harness::Codex), ("gemini-0.58.jsonl", Harness::Gemini)]
689        {
690            assert_eq!(harness::detect(&fixtures.join(file)), Some(want), "{file}");
691        }
692    }
693}