1use 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 pub stopped_window: Duration,
19 pub fs_scan_interval: Duration,
21 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 mcp_memory: HashMap<u32, McpMemory>,
45}
46
47#[derive(Debug, Clone)]
49struct McpMemory {
50 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 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 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 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 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 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 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 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 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 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 self.mcp_memory.retain(|pid, _| by_pid.contains_key(pid));
350 origins
351 }
352}
353
354fn 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
364pub 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
412fn 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 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
439fn normalise(s: &str) -> String {
442 s.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_lowercase()).collect()
443}
444
445fn 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 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 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 assert_eq!(by_name["linear"].pid, None);
555 assert_eq!(by_name["linear"].matched_by, McpMatch::TranscriptOnly);
556
557 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 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 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 #[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 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 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 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}