From fb431d0f94de28cd0507f623b07e644147b7e4d7 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 11:59:55 +0800 Subject: [PATCH 01/10] feat(sight): add LineageTree with classify Introduce a userspace blood lineage tree that tracks Agent process families (Agent -> SubAgent -> Tool / Skill). Nodes carry pid/ppid, process type, AGENT_MODE flag, comm and an optional agent name, and maintain parent->child links on insert/remove. classify() assigns a type from the process's ancestry and environment: a child of an Agent/SubAgent becomes SubAgent (if it matches an agent pattern) or Tool; a parentless process with AGENT_MODE=1 becomes an Agent root; everything else stays Unknown. subtree()/roots() expose the forest for queries. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/lib.rs | 1 + src/agentsight/src/lineage/mod.rs | 304 ++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/agentsight/src/lineage/mod.rs diff --git a/src/agentsight/src/lib.rs b/src/agentsight/src/lib.rs index c730a3d66..80abdf5e4 100644 --- a/src/agentsight/src/lib.rs +++ b/src/agentsight/src/lib.rs @@ -37,6 +37,7 @@ pub mod storage; pub mod chrome_trace; pub mod discovery; pub mod health; +pub mod lineage; pub mod tokenizer; pub mod genai; pub mod atif; diff --git a/src/agentsight/src/lineage/mod.rs b/src/agentsight/src/lineage/mod.rs new file mode 100644 index 000000000..0601c3106 --- /dev/null +++ b/src/agentsight/src/lineage/mod.rs @@ -0,0 +1,304 @@ +//! Blood lineage tree for AI agent process tracking +//! +//! Maintains a userspace mirror of the BPF lineage_tree map, enriched with +//! process type classification (Agent / SubAgent / Tool / Skill). + +use std::collections::HashMap; + +use serde::Serialize; + +/// Process type classification for lineage tree nodes +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ProcessType { + Unknown, + Agent, + SubAgent, + Tool, + Skill, +} + +impl ProcessType { + pub fn from_u32(v: u32) -> Self { + match v { + 1 => Self::Agent, + 2 => Self::SubAgent, + 3 => Self::Tool, + 4 => Self::Skill, + _ => Self::Unknown, + } + } + + pub fn as_u32(&self) -> u32 { + match self { + Self::Unknown => 0, + Self::Agent => 1, + Self::SubAgent => 2, + Self::Tool => 3, + Self::Skill => 4, + } + } +} + +/// Flags on a lineage node (mirrors LINEAGE_FLAG_* from BPF) +pub const LINEAGE_FLAG_AGENT_MODE: u32 = 1 << 0; + +/// A single node in the lineage tree +#[derive(Debug, Clone, Serialize)] +pub struct LineageNode { + pub pid: u32, + pub ppid: u32, + pub process_type: ProcessType, + pub flags: u32, + pub create_time_ns: u64, + pub comm: String, + pub agent_name: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +impl LineageNode { + pub fn has_agent_mode(&self) -> bool { + self.flags & LINEAGE_FLAG_AGENT_MODE != 0 + } +} + +/// Userspace lineage tree — mirrors BPF lineage_tree map with classification +pub struct LineageTree { + nodes: HashMap, +} + +impl LineageTree { + pub fn new() -> Self { + Self { + nodes: HashMap::new(), + } + } + + /// Insert or update a node. Automatically maintains parent→child links. + pub fn insert(&mut self, node: LineageNode) { + let pid = node.pid; + let ppid = node.ppid; + + // Add this pid as a child of its parent + if let Some(parent) = self.nodes.get_mut(&ppid) { + if !parent.children.contains(&pid) { + parent.children.push(pid); + } + } + + self.nodes.insert(pid, node); + } + + /// Remove a node and clean up parent→child links. + pub fn remove(&mut self, pid: u32) -> Option { + let node = self.nodes.remove(&pid)?; + + // Remove from parent's children list + if let Some(parent) = self.nodes.get_mut(&node.ppid) { + parent.children.retain(|&c| c != pid); + } + + Some(node) + } + + /// Get a reference to a node + pub fn get(&self, pid: u32) -> Option<&LineageNode> { + self.nodes.get(&pid) + } + + /// Get a mutable reference to a node + pub fn get_mut(&mut self, pid: u32) -> Option<&mut LineageNode> { + self.nodes.get_mut(&pid) + } + + /// Classify a newly added process based on its ancestry and environment. + /// + /// Rules (evaluated in order): + /// 1. Parent is Agent/SubAgent → child inherits lineage: + /// - matches agent pattern → SubAgent + /// - otherwise → Tool + /// 2. No tracked parent + AGENT_MODE=1 in env → Agent (new root) + /// 3. Otherwise → Unknown + /// + /// Note: child processes inherit AGENT_MODE=1 via environment, but that + /// does NOT make them Agents — only top-level processes (without a tracked + /// parent) are classified as Agent via AGENT_MODE. + pub fn classify( + &mut self, + pid: u32, + has_agent_mode_env: bool, + matches_agent_pattern: bool, + ) { + let ppid = match self.nodes.get(&pid) { + Some(n) => n.ppid, + None => return, + }; + + let parent_in_tree = self.nodes.get(&ppid); + let parent_type = parent_in_tree + .map(|p| p.process_type) + .unwrap_or(ProcessType::Unknown); + + let process_type = match parent_type { + ProcessType::Agent | ProcessType::SubAgent => { + if matches_agent_pattern { + ProcessType::SubAgent + } else { + ProcessType::Tool + } + } + _ => { + if has_agent_mode_env { + ProcessType::Agent + } else { + ProcessType::Unknown + } + } + }; + + if let Some(node) = self.nodes.get_mut(&pid) { + node.process_type = process_type; + } + } + + /// Get the full subtree rooted at `pid` as a serializable structure + pub fn subtree(&self, pid: u32) -> Option { + self.subtree_inner(pid, 0) + } + + fn subtree_inner(&self, pid: u32, depth: u32) -> Option { + if depth > 64 { + return None; + } + let node = self.nodes.get(&pid)?; + let children = node + .children + .iter() + .filter_map(|&cpid| self.subtree_inner(cpid, depth + 1)) + .collect(); + Some(LineageSubtree { + pid: node.pid, + ppid: node.ppid, + process_type: node.process_type, + flags: node.flags, + create_time_ns: node.create_time_ns, + comm: node.comm.clone(), + agent_name: node.agent_name.clone(), + children, + }) + } + + /// Return all root nodes (nodes whose ppid is not in the tree) + pub fn roots(&self) -> Vec { + self.nodes + .values() + .filter(|n| !self.nodes.contains_key(&n.ppid)) + .map(|n| n.pid) + .collect() + } + + /// Snapshot the entire tree as a flat list (for REST API) + pub fn snapshot(&self) -> Vec<&LineageNode> { + self.nodes.values().collect() + } + + pub fn len(&self) -> usize { + self.nodes.len() + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } +} + +/// Recursive subtree for JSON serialization +#[derive(Debug, Clone, Serialize)] +pub struct LineageSubtree { + pub pid: u32, + pub ppid: u32, + pub process_type: ProcessType, + pub flags: u32, + pub create_time_ns: u64, + pub comm: String, + pub agent_name: Option, + pub children: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_node(pid: u32, ppid: u32, ptype: ProcessType) -> LineageNode { + LineageNode { + pid, + ppid, + process_type: ptype, + flags: 0, + create_time_ns: 0, + comm: format!("proc-{}", pid), + agent_name: None, + children: Vec::new(), + } + } + + #[test] + fn test_insert_and_parent_link() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Tool)); + + let parent = tree.get(100).unwrap(); + assert_eq!(parent.children, vec![200]); + } + + #[test] + fn test_remove_cleans_parent() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Tool)); + tree.remove(200); + + let parent = tree.get(100).unwrap(); + assert!(parent.children.is_empty()); + } + + #[test] + fn test_classify_agent_mode() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Unknown)); + tree.classify(100, true, false); + assert_eq!(tree.get(100).unwrap().process_type, ProcessType::Agent); + } + + #[test] + fn test_classify_tool_under_agent() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Unknown)); + tree.classify(200, false, false); + assert_eq!(tree.get(200).unwrap().process_type, ProcessType::Tool); + } + + #[test] + fn test_classify_subagent() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Unknown)); + tree.classify(200, false, true); + assert_eq!(tree.get(200).unwrap().process_type, ProcessType::SubAgent); + } + + #[test] + fn test_roots() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Tool)); + tree.insert(make_node(300, 2, ProcessType::Agent)); + + let roots = tree.roots(); + assert_eq!(roots.len(), 2); + assert!(roots.contains(&100)); + assert!(roots.contains(&300)); + } +} From 212dcb4c0f13ee5991ebc4cfb65bfcc30b114257 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 12:00:09 +0800 Subject: [PATCH 02/10] feat(sight): wire lineage to proctrace+AGENT_MODE Wire the lineage tree into the event loop. proctrace exec/exit events maintain the tree (insert+classify on exec, remove on exit), inferring AGENT_MODE and agent-pattern matches from the pid->agent-name cache to avoid redundant /proc reads. Add scanner helpers read_ppid() and has_agent_mode() that read /proc//stat and /proc//environ, used by the procmon path to auto-detect AGENT_MODE=1 roots. ensure_lineage_node() closes a race: proctrace does not emit an exec event for an AGENT_MODE root (it was not yet in traced_processes when it execed), so the procmon detection path inserts and classifies the node directly, making detection order-independent. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/discovery/scanner.rs | 23 ++++ src/agentsight/src/unified.rs | 146 +++++++++++++++++++++++- 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/src/agentsight/src/discovery/scanner.rs b/src/agentsight/src/discovery/scanner.rs index f1873db90..2fdb97bb0 100644 --- a/src/agentsight/src/discovery/scanner.rs +++ b/src/agentsight/src/discovery/scanner.rs @@ -282,6 +282,29 @@ impl AgentScanner { } } +/// Read the parent PID of a process from /proc/[pid]/stat +pub fn read_ppid(pid: u32) -> Option { + let data = fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?; + // Format: pid (comm) state ppid ... — ppid is the 4th field. + // comm may contain spaces/parens, so find the closing ')' first. + let after_comm = data.rsplit_once(')')?.1; + let ppid_str = after_comm.split_whitespace().nth(1)?; + ppid_str.parse().ok() +} + +/// Check if a process has AGENT_MODE=1 in its environment +/// +/// Reads /proc/[pid]/environ and looks for the AGENT_MODE=1 variable. +pub fn has_agent_mode(pid: u32) -> bool { + let path = format!("/proc/{}/environ", pid); + match fs::read(&path) { + Ok(data) => data + .split(|&b| b == 0) + .any(|entry| entry == b"AGENT_MODE=1"), + Err(_) => false, + } +} + /// Read and parse cmdline file /// /// The cmdline file contains arguments separated by null bytes. diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index fcca60c30..780f07389 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -99,6 +99,8 @@ pub struct AgentSight { sls_activated: Arc, /// Mailbox for watcher thread to deposit a dynamically-created LogtailExporter pending_logtail: Arc>>>, + /// Blood lineage tree tracking process parent-child relationships and type classification + lineage_tree: Arc>, } /// GenAI events waiting for session_id resolution via ResponseSessionMapper. @@ -463,6 +465,7 @@ impl AgentSight { http_domains, sls_activated, pending_logtail, + lineage_tree: Arc::new(std::sync::RwLock::new(crate::lineage::LineageTree::new())), }) } @@ -541,6 +544,11 @@ impl AgentSight { return None; } + // Maintain lineage tree from proctrace exec/exit events + if let Event::Proc(ref proc_event) = event { + self.update_lineage_from_proc(proc_event); + } + // Handle FileWatch events via callback (not through the pipeline) if let Event::FileWatch(ref fw_event) = event { self.handle_filewatch_event(fw_event); @@ -717,7 +725,7 @@ impl AgentSight { use crate::probes::procmon::Event as ProcMonEvent; match event { - ProcMonEvent::Exec { pid, comm, .. } => { + ProcMonEvent::Exec { pid, ppid, comm, .. } => { // Read cmdline for deny-check and custom matching let cmdline_args = crate::discovery::scanner::read_cmdline(&format!("/proc/{}/cmdline", pid)); @@ -732,11 +740,45 @@ impl AgentSight { } // Phase 2: check if this is a known agent and start tracking - if let Some(agent) = self.scanner.on_process_create(*pid, comm) { + let matched = self.scanner.on_process_create(*pid, comm); + let matches_agent_pattern = matched.is_some(); + + if let Some(agent) = matched { let agent_name = agent.agent_info.name.clone(); self.pid_agent_name_cache.insert(*pid, agent_name.clone()); self.attach_process(*pid, &agent_name); } + + // Phase 3: userspace AGENT_MODE=1 detection (complements BPF layer) + // Skip if already attached by cmdline rules or previously attached. + // Also skip if the parent is already tracked — that means this + // process inherited AGENT_MODE from its parent, not set it itself. + if !matches_agent_pattern + && !self.pid_agent_name_cache.contains_key(pid) + && crate::discovery::scanner::has_agent_mode(*pid) + { + let parent_tracked = crate::discovery::scanner::read_ppid(*pid) + .is_some_and(|ppid| self.pid_agent_name_cache.contains_key(&ppid)); + if parent_tracked { + log::debug!( + "ProcMon: pid={} has AGENT_MODE=1 but parent is already tracked, skipping", + pid + ); + } else { + log::info!( + "ProcMon: pid={} detected AGENT_MODE=1 via /proc environ, auto-attaching", + pid + ); + let agent_name = format!("agent-mode-{}", comm); + self.pid_agent_name_cache.insert(*pid, agent_name.clone()); + self.attach_process(*pid, &agent_name); + // Insert into the lineage tree directly. proctrace does not + // emit an exec event for an AGENT_MODE root (it was not yet in + // traced_processes when it execed), so without this the root + // would never appear in the tree. + self.ensure_lineage_node(*pid, *ppid, comm, Some(agent_name), true, false); + } + } } ProcMonEvent::Exit { pid, .. } => { // Remove from tracking if it was an agent @@ -748,6 +790,106 @@ impl AgentSight { } } + /// Ensure a lineage node exists and is correctly classified after procmon + /// provides agent info. Handles the race where the proctrace exec event is + /// missed because the process was not yet in traced_processes when it execed. + fn ensure_lineage_node( + &mut self, + pid: u32, + ppid: u32, + comm: &str, + agent_name: Option, + has_agent_mode: bool, + matches_agent: bool, + ) { + if let Ok(mut tree) = self.lineage_tree.write() { + if tree.get(pid).is_none() { + let node = crate::lineage::LineageNode { + pid, + ppid, + process_type: crate::lineage::ProcessType::Unknown, + flags: if has_agent_mode { + crate::lineage::LINEAGE_FLAG_AGENT_MODE + } else { + 0 + }, + create_time_ns: 0, + comm: comm.to_string(), + agent_name, + children: Vec::new(), + }; + tree.insert(node); + } + tree.classify(pid, has_agent_mode, matches_agent); + } + } + + /// Update lineage tree from proctrace exec/exit events + fn update_lineage_from_proc(&mut self, event: &crate::probes::proctrace::VariableEvent) { + use crate::probes::proctrace::VariableEvent; + + match event { + VariableEvent::Exec { header, .. } => { + let comm_bytes: Vec = header + .comm + .iter() + .map(|&c| c as u8) + .take_while(|&b| b != 0) + .collect(); + let comm = String::from_utf8_lossy(&comm_bytes).into_owned(); + + let agent_name = self.pid_agent_name_cache.get(&header.pid).cloned(); + let matches_agent = agent_name + .as_ref() + .is_some_and(|n| !n.starts_with("agent-mode-")); + // Infer AGENT_MODE from cache: if procmon already detected it, the + // name starts with "agent-mode-". Avoids redundant /proc/pid/environ I/O. + let has_agent_mode = agent_name + .as_ref() + .is_some_and(|n| n.starts_with("agent-mode-")); + + let node = crate::lineage::LineageNode { + pid: header.pid, + ppid: header.ppid, + process_type: crate::lineage::ProcessType::Unknown, + flags: if has_agent_mode { + crate::lineage::LINEAGE_FLAG_AGENT_MODE + } else { + 0 + }, + create_time_ns: header.timestamp_ns, + comm, + agent_name, + children: Vec::new(), + }; + + if let Ok(mut tree) = self.lineage_tree.write() { + tree.insert(node); + tree.classify(header.pid, has_agent_mode, matches_agent); + + log::debug!( + "Lineage: added pid={} ppid={} type={:?}", + header.pid, + header.ppid, + tree.get(header.pid).map(|n| n.process_type), + ); + } + } + VariableEvent::Exit { header, .. } => { + if let Ok(mut tree) = self.lineage_tree.write() { + if let Some(removed) = tree.remove(header.pid) { + log::debug!( + "Lineage: removed pid={} type={:?}", + header.pid, + removed.process_type, + ); + } + } + } + _ => {} + } + } + /// Handle FileWatch event via registered callback fn handle_filewatch_event(&self, event: &FileWatchEvent) { log::debug!("FileWatch: pid={} file={}", event.pid, event.filename); From 8b083b99af532ae9e00d9dfe55f128d4db1d2cfd Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 12:02:53 +0800 Subject: [PATCH 03/10] feat(sight): add find_root to lineage tree Walk a process's ancestry to the nearest Agent root (bounded to 64 hops to guard against cycles), returning None when no Agent ancestor exists. This is the prerequisite the scheduler uses to group a process into its Agent family. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/lineage/mod.rs | 46 +++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/agentsight/src/lineage/mod.rs b/src/agentsight/src/lineage/mod.rs index 0601c3106..a6b61a710 100644 --- a/src/agentsight/src/lineage/mod.rs +++ b/src/agentsight/src/lineage/mod.rs @@ -210,6 +210,24 @@ impl LineageTree { pub fn is_empty(&self) -> bool { self.nodes.is_empty() } + + /// Walk up from `pid` to find the Agent root of its family. + /// Returns the PID of the nearest Agent ancestor, or `pid` itself if it is an Agent. + /// Returns None if no Agent is found in the ancestry chain. + pub fn find_root(&self, pid: u32) -> Option { + let mut current = pid; + for _ in 0..64 { + let node = self.nodes.get(¤t)?; + if node.process_type == ProcessType::Agent { + return Some(current); + } + if !self.nodes.contains_key(&node.ppid) { + return None; + } + current = node.ppid; + } + None + } } /// Recursive subtree for JSON serialization @@ -301,4 +319,32 @@ mod tests { assert!(roots.contains(&100)); assert!(roots.contains(&300)); } + + #[test] + fn test_find_root_agent() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Agent)); + tree.insert(make_node(200, 100, ProcessType::Tool)); + tree.insert(make_node(300, 200, ProcessType::Tool)); + + assert_eq!(tree.find_root(100), Some(100)); + assert_eq!(tree.find_root(200), Some(100)); + assert_eq!(tree.find_root(300), Some(100)); + } + + #[test] + fn test_find_root_no_agent() { + let mut tree = LineageTree::new(); + tree.insert(make_node(100, 1, ProcessType::Unknown)); + tree.insert(make_node(200, 100, ProcessType::Unknown)); + + assert_eq!(tree.find_root(100), None); + assert_eq!(tree.find_root(200), None); + } + + #[test] + fn test_find_root_nonexistent_pid() { + let tree = LineageTree::new(); + assert_eq!(tree.find_root(999), None); + } } From ef8a5822c9546e2df02c4b872b21f2c90da24657 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 13:14:20 +0800 Subject: [PATCH 04/10] feat(sight): add schedmon BPF tracepoints Detect idle/active transitions of traced processes via the BTF-typed sched_switch / sched_wakeup tracepoints. tp_btf gives direct typed access to the task_struct, which is what makes correct detection possible: - sched_switch reads the raw prev->__state and the `preempt` flag, so a task that is only preempted while still runnable is not misread as going to sleep (the format-struct prev_state field is the encoded TASK_REPORT value, which is essentially never 0 and would flag every context switch as a sleep). - both tracepoints resolve tgid (to filter traced Agent families) and tid (the actual thread) from the task_struct, and emit per-tid, so a multithreaded process can be ACTIVE while any one of its threads runs. Per-tid state-dedup (no time cooldown) avoids re-emitting the same state while still delivering every genuine transition; the LRU map self-evicts since schedmon has no thread-exit hook. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/build.rs | 6 +- src/agentsight/src/bpf/common.h | 1 + src/agentsight/src/bpf/schedmon.bpf.c | 115 ++++++++++++++++++++++++++ src/agentsight/src/bpf/schedmon.h | 35 ++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 src/agentsight/src/bpf/schedmon.bpf.c create mode 100644 src/agentsight/src/bpf/schedmon.h diff --git a/src/agentsight/build.rs b/src/agentsight/build.rs index f6b420a64..b6691c6c6 100644 --- a/src/agentsight/build.rs +++ b/src/agentsight/build.rs @@ -61,7 +61,11 @@ fn main() { // Generate tcpsniff skeleton (no header — reuses sslsniff.h event format) generate_skeleton(&mut out, "tcpsniff"); - + + // Generate schedmon skeleton and bindings + generate_skeleton(&mut out, "schedmon"); + generate_header(&mut out, "schedmon"); + // generate_header(&mut out, "frametypes"); // generate_header(&mut out, "errors"); // generate_header(&mut out, "stackdeltatypes"); diff --git a/src/agentsight/src/bpf/common.h b/src/agentsight/src/bpf/common.h index c99dbb79a..fc8b025e5 100644 --- a/src/agentsight/src/bpf/common.h +++ b/src/agentsight/src/bpf/common.h @@ -23,6 +23,7 @@ typedef enum { EVENT_SOURCE_FILEWATCH = 4, // File watch events (filewatch) EVENT_SOURCE_FILEWRITE = 5, // File write events (filewrite) EVENT_SOURCE_UDPDNS = 6, // UDP DNS query events (udpdns) + EVENT_SOURCE_SCHED = 7, // Scheduler state events (schedmon) } event_source_t; // Common event header - every ringbuffer event MUST start with this diff --git a/src/agentsight/src/bpf/schedmon.bpf.c b/src/agentsight/src/bpf/schedmon.bpf.c new file mode 100644 index 000000000..e05d3e256 --- /dev/null +++ b/src/agentsight/src/bpf/schedmon.bpf.c @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// Scheduler monitor BPF program +// Detects idle/active state transitions for traced Agent processes via the +// BTF-typed sched_switch / sched_wakeup tracepoints. +// +// Using tp_btf (rather than the format-struct tracepoints) gives direct typed +// access to the task_struct, which lets us: +// 1. read the *raw* prev->__state (TASK_RUNNING == 0) and the `preempt` flag, +// so a task that is merely preempted while still runnable is NOT treated as +// going to sleep — only a genuinely blocking task emits SLEEP; +// 2. filter by tgid (thread-group id) so we only watch traced Agent families, +// but emit per-tid (thread id), so userspace can tell a family is ACTIVE +// while ANY of its threads is runnable (a multithreaded process commonly has +// some threads sleeping while others run). +// +// Events are rate-limited per-tid to avoid flooding the shared ring buffer. +#include "vmlinux.h" +#include +#include +#include +#include "schedmon.h" +#include "common.h" + +struct pid_sched_state { + u8 last_state; // sched_event_type +}; + +// Per-tid last emitted state, used to deduplicate consecutive same-state events +// (a running thread does not re-emit WAKEUP). We deliberately do NOT apply a +// time-based cooldown: a thread can legitimately wake and immediately block again +// (WAKEUP then SLEEP within microseconds), and dropping that SLEEP would strand +// the thread in the userspace runnable set, pinning its family ACTIVE forever. +// LRU so it self-evicts (schedmon has no thread-exit hook) and update never fails. +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, MAX_TRACED_PROCESSES); + __type(key, u32); + __type(value, struct pid_sched_state); +} pid_sched_state SEC(".maps"); + +static __always_inline int +emit_sched_event(u32 tgid, u32 tid, u64 now, u8 event_type) +{ + struct pid_sched_state *st = bpf_map_lookup_elem(&pid_sched_state, &tid); + if (st) { + if (st->last_state == event_type) + return 0; + st->last_state = event_type; + } else { + struct pid_sched_state new_st = { + .last_state = event_type, + }; + bpf_map_update_elem(&pid_sched_state, &tid, &new_st, BPF_ANY); + } + + struct sched_event *ev = bpf_ringbuf_reserve(&rb, sizeof(*ev), 0); + if (!ev) + return 0; + + ev->source = EVENT_SOURCE_SCHED; + ev->tgid = tgid; + ev->tid = tid; + ev->timestamp_ns = now; + ev->event_type = event_type; + ev->pad[0] = 0; + ev->pad[1] = 0; + ev->pad[2] = 0; + + bpf_ringbuf_submit(ev, 0); + return 0; +} + +// sched_switch: prev (one thread) is going off-CPU. +// Emit SLEEP only when prev is actually blocking (not merely preempted while +// still runnable). Filtered by tgid, emitted per-tid. +SEC("tp_btf/sched_switch") +int BPF_PROG(handle_sched_switch, bool preempt, struct task_struct *prev, + struct task_struct *next) +{ + // Preempted tasks stay runnable — they are not going to sleep. + if (preempt) + return 0; + + // prev->__state has already been set to the target state by the scheduler. + // TASK_RUNNING (0) means it is still runnable (e.g. yielded); skip it. + unsigned int state = BPF_CORE_READ(prev, __state); + if (state == 0) + return 0; + + u32 tgid = BPF_CORE_READ(prev, tgid); + u32 *traced = bpf_map_lookup_elem(&traced_processes, &tgid); + if (!traced) + return 0; + + u32 tid = BPF_CORE_READ(prev, pid); + return emit_sched_event(tgid, tid, bpf_ktime_get_ns(), SCHED_EVENT_SLEEP); +} + +// sched_wakeup: task p (one thread) is being woken up. Any thread waking makes +// its family ACTIVE. Filtered by tgid, emitted per-tid. +SEC("tp_btf/sched_wakeup") +int BPF_PROG(handle_sched_wakeup, struct task_struct *p) +{ + u32 tgid = BPF_CORE_READ(p, tgid); + u32 *traced = bpf_map_lookup_elem(&traced_processes, &tgid); + if (!traced) + return 0; + + u32 tid = BPF_CORE_READ(p, pid); + return emit_sched_event(tgid, tid, bpf_ktime_get_ns(), SCHED_EVENT_WAKEUP); +} + +char LICENSE[] SEC("license") = "GPL"; diff --git a/src/agentsight/src/bpf/schedmon.h b/src/agentsight/src/bpf/schedmon.h new file mode 100644 index 000000000..46b6167d5 --- /dev/null +++ b/src/agentsight/src/bpf/schedmon.h @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// Scheduler monitor BPF program header +// Detects idle/active state transitions for traced Agent processes +#ifndef __SCHEDMON_H +#define __SCHEDMON_H + +#define TASK_COMM_LEN 16 + +typedef signed char s8; +typedef unsigned char u8; +typedef signed short s16; +typedef unsigned short u16; +typedef signed int s32; +typedef unsigned int u32; +typedef signed long long s64; +typedef unsigned long long u64; + +enum sched_event_type { + SCHED_EVENT_SLEEP = 1, + SCHED_EVENT_WAKEUP = 2, +}; + +struct sched_event { + u32 source; // EVENT_SOURCE_SCHED + u32 tgid; // thread-group id — identifies the Agent family + u32 tid; // thread id — tracked individually so a family is ACTIVE + // while ANY of its threads is runnable + u64 timestamp_ns; + u8 event_type; // enum sched_event_type + u8 pad[3]; +}; + +#endif /* __SCHEDMON_H */ From cb069a8ee04a7bd5d9ef1ec96c5c02d0e4af0a00 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 13:14:30 +0800 Subject: [PATCH 05/10] feat(sight): wire schedmon probe+Sched event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the SchedMon probe (reuses the shared traced_processes and ring-buffer maps, attaches the two BTF tracepoints) and the Event::Sched variant carrying (tgid, tid, event_type). The unified parser treats Sched events as a no-op — they are consumed by the scheduler, not parsed into messages. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/event.rs | 16 +++ src/agentsight/src/parser/unified.rs | 1 + src/agentsight/src/probes/mod.rs | 4 +- src/agentsight/src/probes/schedmon.rs | 161 ++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/agentsight/src/probes/schedmon.rs diff --git a/src/agentsight/src/event.rs b/src/agentsight/src/event.rs index fde41b4c1..449deff03 100644 --- a/src/agentsight/src/event.rs +++ b/src/agentsight/src/event.rs @@ -4,6 +4,7 @@ use crate::probes::procmon::Event as ProcMonEvent; use crate::probes::filewatch::FileWatchEvent; use crate::probes::filewrite::FileWriteEvent; use crate::probes::udpdns::UdpDnsEvent; +use crate::probes::schedmon::SchedEvent; /// Unified event type that can represent any probe event /// @@ -16,6 +17,7 @@ pub enum Event { FileWatch(FileWatchEvent), FileWrite(FileWriteEvent), UdpDns(UdpDnsEvent), + Sched(SchedEvent), } impl Event { @@ -28,6 +30,7 @@ impl Event { Event::FileWatch(_) => "FileWatch", Event::FileWrite(_) => "FileWrite", Event::UdpDns(_) => "UdpDns", + Event::Sched(_) => "Sched", } } } @@ -110,6 +113,19 @@ impl Event { _ => None, } } + + /// Check if this is a scheduler state event + pub fn is_sched(&self) -> bool { + matches!(self, Event::Sched(_)) + } + + /// Get scheduler event if this is one + pub fn as_sched(&self) -> Option<&SchedEvent> { + match self { + Event::Sched(e) => Some(e), + _ => None, + } + } } #[cfg(test)] diff --git a/src/agentsight/src/parser/unified.rs b/src/agentsight/src/parser/unified.rs index 70e62ff8b..b88e2544b 100644 --- a/src/agentsight/src/parser/unified.rs +++ b/src/agentsight/src/parser/unified.rs @@ -141,6 +141,7 @@ impl Parser { Event::FileWatch(_) => ParseResult { messages: Vec::new() }, Event::FileWrite(_) => ParseResult { messages: Vec::new() }, Event::UdpDns(_) => ParseResult { messages: Vec::new() }, + Event::Sched(_) => ParseResult { messages: Vec::new() }, } } diff --git a/src/agentsight/src/probes/mod.rs b/src/agentsight/src/probes/mod.rs index ebd022ea1..defa83384 100644 --- a/src/agentsight/src/probes/mod.rs +++ b/src/agentsight/src/probes/mod.rs @@ -7,6 +7,7 @@ pub mod filewatch; pub mod filewrite; pub mod udpdns; pub mod tcpsniff; +pub mod schedmon; pub mod probes; // Re-export commonly used types @@ -17,4 +18,5 @@ pub use procmon::{ProcMon, ProcMonEvent, Event as ProcMonEventExt}; pub use filewatch::{FileWatch, FileWatchEvent}; pub use filewrite::{FileWrite as FileWriteProbe, FileWriteEvent}; pub use udpdns::{UdpDns, UdpDnsEvent}; -pub use tcpsniff::TcpSniff; \ No newline at end of file +pub use tcpsniff::TcpSniff; +pub use schedmon::{SchedMon, SchedEvent}; \ No newline at end of file diff --git a/src/agentsight/src/probes/schedmon.rs b/src/agentsight/src/probes/schedmon.rs new file mode 100644 index 000000000..f9d3c954f --- /dev/null +++ b/src/agentsight/src/probes/schedmon.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) +// Copyright (c) 2025 AgentSight Project +// +// Scheduler monitor probe — detects idle/active state transitions for traced +// Agent processes via the BTF-typed sched_switch / sched_wakeup tracepoints. + +use crate::config; +use anyhow::{Context, Result}; +use libbpf_rs::{ + Link, MapHandle, + skel::{OpenSkel, SkelBuilder}, +}; +use std::{ + mem::MaybeUninit, + os::fd::AsFd, +}; + +mod bpf { + include!(concat!(env!("OUT_DIR"), "/schedmon.skel.rs")); + include!(concat!(env!("OUT_DIR"), "/schedmon.rs")); +} +use bpf::*; + +pub type RawSchedEvent = bpf::sched_event; + +pub const SCHED_EVENT_SLEEP: u8 = 1; +pub const SCHED_EVENT_WAKEUP: u8 = 2; + +#[derive(Debug, Clone)] +pub struct SchedEvent { + /// Thread-group id — identifies the Agent family. + pub tgid: u32, + /// Thread id — tracked individually (a family is ACTIVE while any thread runs). + pub tid: u32, + pub timestamp_ns: u64, + pub event_type: u8, +} + +impl SchedEvent { + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < std::mem::size_of::() { + return None; + } + + let raw = unsafe { &*(data.as_ptr() as *const RawSchedEvent) }; + + Some(SchedEvent { + tgid: raw.tgid, + tid: raw.tid, + timestamp_ns: config::ktime_to_unix_ns(raw.timestamp_ns), + event_type: raw.event_type, + }) + } + + pub fn is_sleep(&self) -> bool { + self.event_type == SCHED_EVENT_SLEEP + } + + pub fn is_wakeup(&self) -> bool { + self.event_type == SCHED_EVENT_WAKEUP + } +} + +pub struct SchedMon { + // Vestigial under libbpf-rs 0.23.x: `builder.open()` takes no OpenObject and + // owns its bpf_object internally, so this field is unused (kept for parity + // with the sibling probes). TODO: on upgrade to libbpf-rs 0.24+, `open()` + // requires `&mut MaybeUninit` and the 'static lifetime laundering + // below must be revisited across all probes. + _open_object: Box>, + skel: Box>, + _links: Vec, +} + +impl SchedMon { + pub fn new_with_maps(traced_processes: &MapHandle, rb: &MapHandle) -> Result { + let mut builder = SchedmonSkelBuilder::default(); + builder.obj_builder.debug(config::verbose()); + + let open_object = Box::new(MaybeUninit::::uninit()); + let mut open_skel = builder.open().context("failed to open schedmon BPF object")?; + + open_skel + .maps_mut() + .traced_processes() + .reuse_fd(traced_processes.as_fd()) + .context("failed to reuse traced_processes map")?; + + open_skel + .maps_mut() + .rb() + .reuse_fd(rb.as_fd()) + .context("failed to reuse rb map")?; + + let skel = open_skel.load().context("failed to load schedmon BPF object")?; + + let skel = + unsafe { Box::from_raw(Box::into_raw(Box::new(skel)) as *mut SchedmonSkel<'static>) }; + + Ok(Self { + _open_object: open_object, + skel, + _links: Vec::new(), + }) + } + + pub fn attach(&mut self) -> Result<()> { + let mut links = Vec::new(); + + let link = self + .skel + .progs_mut() + .handle_sched_switch() + .attach() + .context("failed to attach sched_switch tracepoint")?; + links.push(link); + + let link = self + .skel + .progs_mut() + .handle_sched_wakeup() + .attach() + .context("failed to attach sched_wakeup tracepoint")?; + links.push(link); + + self._links = links; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sched_event_from_bytes_too_short() { + let data = [0u8; 4]; + assert!(SchedEvent::from_bytes(&data).is_none()); + } + + #[test] + fn test_sched_event_is_sleep_wakeup() { + let ev = SchedEvent { + tgid: 100, + tid: 100, + timestamp_ns: 12345, + event_type: SCHED_EVENT_SLEEP, + }; + assert!(ev.is_sleep()); + assert!(!ev.is_wakeup()); + + let ev2 = SchedEvent { + tgid: 100, + tid: 101, + timestamp_ns: 12345, + event_type: SCHED_EVENT_WAKEUP, + }; + assert!(ev2.is_wakeup()); + assert!(!ev2.is_sleep()); + } +} From 8a7ef1acb171d276f4c54d746d02d7311f48e735 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 13:14:44 +0800 Subject: [PATCH 06/10] feat(sight): add idle-burst-idle cgroup scheduler Group an Agent family (keyed by the Agent root PID) into a cgroup v2 cpu cgroup and drive cpu.idle from the family's aggregate scheduling state: ACTIVE (cpu.idle=0, cpu.weight=active_weight) while any thread is runnable, IDLE (cpu.idle=1, SCHED_IDLE) once every thread has been blocked longer than idle_threshold_ms. A per-tid runnable set makes "any thread runnable" correct for multithreaded processes. Details that the kernel forces: - cpu.idle is the idle mechanism; we never write cpu.weight while idle (the kernel rejects it and ignores the value), and clear cpu.idle before restoring cpu.weight on the ACTIVE transition. - cpu controller is enabled top-down from the v2 root to cgroup_root so the agent-* leaves actually expose cpu.idle/cpu.weight. Robust teardown so cgroups never leak or strand processes at idle weight: - reap_exited_families() removes a family once its cgroup.procs is empty (proctrace only emits exit for its own child_pids, so an AGENT_MODE root would otherwise never be torn down); - remove_cgroup() evacuates any remaining (fork-without-exec) processes to the cgroup root before rmdir to avoid EBUSY leaks; - a startup sweep clears empty agent-* dirs left by a previous SIGKILL, and Drop cleans up on graceful shutdown. active_weight is clamped to the kernel-valid range; the idle debounce starts only when the runnable set first empties (not on every sleep edge). Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/lib.rs | 1 + src/agentsight/src/scheduler/mod.rs | 572 ++++++++++++++++++++++++++++ 2 files changed, 573 insertions(+) create mode 100644 src/agentsight/src/scheduler/mod.rs diff --git a/src/agentsight/src/lib.rs b/src/agentsight/src/lib.rs index 80abdf5e4..0a99ebf9a 100644 --- a/src/agentsight/src/lib.rs +++ b/src/agentsight/src/lib.rs @@ -43,6 +43,7 @@ pub mod genai; pub mod atif; pub mod response_map; pub mod interruption; +pub mod scheduler; pub mod skill_metrics; pub mod utils; #[cfg(feature = "server")] diff --git a/src/agentsight/src/scheduler/mod.rs b/src/agentsight/src/scheduler/mod.rs new file mode 100644 index 000000000..a3a6d3a60 --- /dev/null +++ b/src/agentsight/src/scheduler/mod.rs @@ -0,0 +1,572 @@ +//! Idle-burst-idle scheduler. +//! +//! Groups the processes of an Agent family (keyed by the Agent root PID) into a +//! cgroup v2 cpu cgroup and drives `cpu.weight` / `cpu.idle` from the family's +//! aggregate scheduling state: ACTIVE while any member is runnable, IDLE once +//! every member has been blocked for longer than `idle_threshold_ms`. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use crate::probes::schedmon::{SCHED_EVENT_SLEEP, SCHED_EVENT_WAKEUP}; + +/// cgroup v2 cpu.weight valid range. +const CPU_WEIGHT_MIN: u32 = 1; +const CPU_WEIGHT_MAX: u32 = 10000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SchedState { + Active, + Idle, +} + +#[derive(Debug, Clone)] +pub struct SchedulerConfig { + pub enabled: bool, + pub active_weight: u32, + pub idle_threshold_ms: u64, + pub cgroup_root: PathBuf, +} + +impl Default for SchedulerConfig { + fn default() -> Self { + Self { + enabled: false, + active_weight: 200, + idle_threshold_ms: 50, + cgroup_root: PathBuf::from("/sys/fs/cgroup/agentsight"), + } + } +} + +struct FamilyState { + cgroup_path: PathBuf, + /// Process ids (tgids) classified into this family, for cgroup membership. + member_pids: HashSet, + /// Thread ids currently runnable. The family is ACTIVE while this is + /// non-empty, so a multithreaded process stays ACTIVE as long as any one of + /// its threads is running even if others are blocked. + active_tids: HashSet, + state: SchedState, + /// Set to the instant `active_tids` became empty while ACTIVE (pending idle). + /// Cleared as soon as any thread becomes runnable again. The debounced IDLE + /// transition in `tick()` fires only after this has aged past the threshold. + idle_since: Option, + cgroup_created: bool, +} + +pub struct Scheduler { + config: SchedulerConfig, + families: HashMap, + pid_to_root: HashMap, +} + +impl Scheduler { + pub fn new(config: SchedulerConfig) -> Self { + // Sweep cgroups leaked by a previous non-graceful exit (SIGKILL/crash): + // remove now-empty agent-* directories under cgroup_root. Non-empty ones + // (a stale escaped process, or another live instance) are left untouched. + sweep_stale_cgroups(&config.cgroup_root); + Self { + config, + families: HashMap::new(), + pid_to_root: HashMap::new(), + } + } + + pub fn add_process(&mut self, pid: u32, root_pid: u32) { + self.pid_to_root.insert(pid, root_pid); + + let active_weight = self.config.active_weight; + let family = self.families.entry(root_pid).or_insert_with(|| { + let cgroup_path = self.config.cgroup_root.join(format!("agent-{root_pid}")); + FamilyState { + cgroup_path, + member_pids: HashSet::new(), + active_tids: HashSet::new(), + state: SchedState::Active, + idle_since: None, + cgroup_created: false, + } + }); + + family.member_pids.insert(pid); + // Seed the process's main thread (tid == tgid == pid) as runnable. Per-tid + // sched events refine the set from here. A family classified late while + // already idle starts ACTIVE and self-corrects on its next wake/sleep + // cycle — the cost is at most one delayed idle window. + family.active_tids.insert(pid); + family.idle_since = None; + + if !family.cgroup_created { + if let Err(e) = create_cgroup(&family.cgroup_path) { + log::warn!("failed to create cgroup {:?}: {e}", family.cgroup_path); + return; + } + if let Err(e) = write_weight(&family.cgroup_path, active_weight) { + log::warn!("failed to set initial cpu.weight: {e}"); + } + if let Err(e) = write_cpu_idle(&family.cgroup_path, false) { + log::debug!("cpu.idle not available: {e}"); + } + family.cgroup_created = true; + } + + if let Err(e) = migrate_pid(&family.cgroup_path, pid) { + log::debug!("failed to migrate pid {pid} to cgroup: {e}"); + } + } + + pub fn remove_process(&mut self, pid: u32) { + let root_pid = match self.pid_to_root.remove(&pid) { + Some(r) => r, + None => return, + }; + + let should_remove = if let Some(family) = self.families.get_mut(&root_pid) { + family.member_pids.remove(&pid); + family.active_tids.remove(&pid); + family.member_pids.is_empty() + } else { + false + }; + + if should_remove { + if let Some(family) = self.families.remove(&root_pid) { + if family.cgroup_created { + if let Err(e) = remove_cgroup(&family.cgroup_path) { + log::warn!("failed to remove cgroup {:?}: {e}", family.cgroup_path); + } + } + } + } + } + + /// Handle a per-thread scheduler event. `tgid` selects the family; `tid` is + /// tracked in the family's runnable set so the family is ACTIVE while any of + /// its threads runs. + pub fn on_sched_event(&mut self, tgid: u32, tid: u32, event_type: u8) { + let root_pid = match self.pid_to_root.get(&tgid) { + Some(&r) => r, + None => return, + }; + + let active_weight = self.config.active_weight; + let family = match self.families.get_mut(&root_pid) { + Some(f) => f, + None => return, + }; + + match event_type { + SCHED_EVENT_WAKEUP => { + family.active_tids.insert(tid); + } + SCHED_EVENT_SLEEP => { + family.active_tids.remove(&tid); + } + _ => return, + } + + if family.active_tids.is_empty() { + // All members blocked. Start the debounce window if we are not + // already counting; the actual IDLE write happens in tick(). + if family.state == SchedState::Active && family.idle_since.is_none() { + family.idle_since = Some(Instant::now()); + } + return; + } + + // A member is runnable again: cancel any pending idle. + family.idle_since = None; + if family.state == SchedState::Idle { + // ACTIVE transition: clear cpu.idle BEFORE setting cpu.weight (the + // kernel rejects weight writes while cpu.idle=1). + family.state = SchedState::Active; + if family.cgroup_created { + if let Err(e) = write_cpu_idle(&family.cgroup_path, false) { + log::debug!("cpu.idle write failed: {e}"); + } + if let Err(e) = write_weight(&family.cgroup_path, active_weight) { + log::warn!("failed to set active weight: {e}"); + } + log::debug!("scheduler: family {root_pid} -> ACTIVE (weight={active_weight})"); + } + } + } + + /// Reap families whose cgroup has no remaining processes (all members exited). + /// This does not depend on process-exit events reaching us — proctrace only + /// emits exit for its own child_pids, so an Agent root detected via AGENT_MODE + /// would otherwise never be torn down. An empty cgroup.procs unambiguously + /// means the whole family is gone (sleeping members are still listed). + fn reap_exited_families(&mut self) { + let dead: Vec = self + .families + .iter() + .filter(|(_, f)| f.cgroup_created && cgroup_is_empty(&f.cgroup_path)) + .map(|(&root, _)| root) + .collect(); + for root in dead { + if let Some(family) = self.families.remove(&root) { + if let Err(e) = remove_cgroup(&family.cgroup_path) { + log::debug!("reap: failed to remove cgroup {:?}: {e}", family.cgroup_path); + } else { + log::debug!("scheduler: reaped exited family {root}"); + } + } + self.pid_to_root.retain(|_, &mut r| r != root); + } + } + + /// Called periodically from the event loop to finalize debounced idle transitions. + pub fn tick(&mut self) { + self.reap_exited_families(); + let threshold = std::time::Duration::from_millis(self.config.idle_threshold_ms); + + for (&root_pid, family) in &mut self.families { + if family.state == SchedState::Idle || !family.active_tids.is_empty() { + continue; + } + match family.idle_since { + Some(t) if t.elapsed() >= threshold => {} + _ => continue, + } + + family.state = SchedState::Idle; + if family.cgroup_created { + // Idle via cpu.idle (SCHED_IDLE): the family only runs when nothing + // else wants the CPU. We do NOT write cpu.weight here — the kernel + // rejects cpu.weight writes while cpu.idle=1 and ignores the value + // anyway. cpu.weight is restored on the ACTIVE transition. + if let Err(e) = write_cpu_idle(&family.cgroup_path, true) { + log::warn!("failed to set cpu.idle: {e}"); + } + log::debug!("scheduler: family {root_pid} -> IDLE (cpu.idle=1)"); + } + } + } + + pub fn family_count(&self) -> usize { + self.families.len() + } + + pub fn family_state(&self, root_pid: u32) -> Option { + self.families.get(&root_pid).map(|f| f.state) + } +} + +impl Drop for Scheduler { + /// Evacuate every family's processes back to the cgroup v2 root and remove + /// the agent-* directories plus the (now-empty) cgroup_root, so we do not + /// leak cgroups or leave migrated PIDs pinned at idle weight after shutdown. + fn drop(&mut self) { + let mut any_created = false; + for family in self.families.values() { + if family.cgroup_created { + any_created = true; + if let Err(e) = remove_cgroup(&family.cgroup_path) { + log::debug!("failed to remove cgroup {:?} on shutdown: {e}", family.cgroup_path); + } + } + } + if any_created && self.config.cgroup_root.exists() { + let _ = std::fs::remove_dir(&self.config.cgroup_root); + } + } +} + +/// Find the cgroup v2 mount root by walking up from `start` while each directory +/// still exposes `cgroup.subtree_control` (i.e. is itself a cgroup v2 node). +fn cgroup_v2_root(start: &Path) -> Option { + let mut root: Option = None; + let mut cur = Some(start.to_path_buf()); + while let Some(dir) = cur { + if !dir.join("cgroup.subtree_control").exists() { + break; + } + root = Some(dir.clone()); + cur = dir.parent().map(|p| p.to_path_buf()); + } + root +} + +/// Enable the `cpu` controller in every cgroup from the v2 root down to (and +/// including) `target`, so that `target`'s children expose cpu.weight/cpu.idle. +/// Each level must be enabled before its child can inherit the controller, so we +/// walk top-down. Real write failures are surfaced (the controller silently +/// missing is the failure mode that makes the whole scheduler a no-op). +fn enable_cpu_controller(target: &Path) { + // Build the chain target -> ... -> v2 root, then enable top-down. + let mut chain: Vec = Vec::new(); + let mut cur = Some(target.to_path_buf()); + while let Some(dir) = cur { + if !dir.join("cgroup.subtree_control").exists() { + break; + } + chain.push(dir.clone()); + cur = dir.parent().map(|p| p.to_path_buf()); + } + for dir in chain.iter().rev() { + let subtree_ctl = dir.join("cgroup.subtree_control"); + // Writing "+cpu" when already enabled is a no-op; only a genuine failure + // (controller not available at this level, permissions) is worth logging. + if let Err(e) = std::fs::write(&subtree_ctl, "+cpu") { + log::warn!("failed to enable cpu controller at {subtree_ctl:?}: {e}"); + } + } +} + +/// True if the cgroup currently holds no processes. Unreadable cgroup.procs +/// (e.g. the dir is gone) returns false so we never reap on a transient error. +fn cgroup_is_empty(path: &Path) -> bool { + match std::fs::read_to_string(path.join("cgroup.procs")) { + Ok(s) => s.split_whitespace().next().is_none(), + Err(_) => false, + } +} + +/// Remove empty `agent-*` cgroups left under `cgroup_root` by a previous +/// non-graceful exit. Best-effort: rmdir skips non-empty directories, so a live +/// instance's families or an escaped process are never disturbed. +fn sweep_stale_cgroups(cgroup_root: &Path) { + let entries = match std::fs::read_dir(cgroup_root) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("agent-")) + { + if std::fs::remove_dir(&path).is_ok() { + log::debug!("scheduler: swept stale cgroup {path:?}"); + } + } + } +} + +fn create_cgroup(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path)?; + // Enable the cpu controller down to the parent so this leaf gets cpu.* files. + if let Some(parent) = path.parent() { + enable_cpu_controller(parent); + } + Ok(()) +} + +/// Move any processes still resident in `path` back to the cgroup v2 root, then +/// rmdir it. rmdir fails with EBUSY while a cgroup still holds processes (e.g. +/// fork-without-exec children that were never tracked), so evacuation first. +fn remove_cgroup(path: &Path) -> std::io::Result<()> { + if !path.exists() { + return Ok(()); + } + if let Some(root) = cgroup_v2_root(path) { + let root_procs = root.join("cgroup.procs"); + if let Ok(procs) = std::fs::read_to_string(path.join("cgroup.procs")) { + for pid in procs.split_whitespace() { + // Best-effort: a pid may have exited between read and write. + let _ = std::fs::write(&root_procs, pid); + } + } + } + std::fs::remove_dir(path) +} + +fn write_weight(cgroup_path: &Path, weight: u32) -> std::io::Result<()> { + let clamped = weight.clamp(CPU_WEIGHT_MIN, CPU_WEIGHT_MAX); + if clamped != weight { + log::warn!("cpu.weight {weight} out of range [{CPU_WEIGHT_MIN}, {CPU_WEIGHT_MAX}], clamped to {clamped}"); + } + std::fs::write(cgroup_path.join("cpu.weight"), clamped.to_string()) +} + +fn write_cpu_idle(cgroup_path: &Path, idle: bool) -> std::io::Result<()> { + let val = if idle { "1" } else { "0" }; + std::fs::write(cgroup_path.join("cpu.idle"), val) +} + +fn migrate_pid(cgroup_path: &Path, pid: u32) -> std::io::Result<()> { + std::fs::write(cgroup_path.join("cgroup.procs"), pid.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn test_config() -> SchedulerConfig { + // Unique cgroup_root per scheduler so the (real) filesystem operations in + // sweep/reap/migrate don't race across tests that share pid numbers. + use std::sync::atomic::{AtomicU32, Ordering}; + static SEQ: AtomicU32 = AtomicU32::new(0); + let n = SEQ.fetch_add(1, Ordering::SeqCst); + SchedulerConfig { + enabled: true, + active_weight: 200, + idle_threshold_ms: 10, + cgroup_root: PathBuf::from(format!("/tmp/test-cgroup-agentsight-{n}")), + } + } + + fn make_scheduler() -> Scheduler { + Scheduler::new(test_config()) + } + + /// Force a family into the "all members idle for long enough" condition. + fn force_idle_window(sched: &mut Scheduler, root_pid: u32) { + if let Some(family) = sched.families.get_mut(&root_pid) { + family.idle_since = Some(Instant::now() - Duration::from_millis(20)); + } + } + + #[test] + fn test_add_remove_process() { + let mut sched = make_scheduler(); + + sched.add_process(100, 100); + assert_eq!(sched.family_count(), 1); + assert_eq!(sched.pid_to_root.get(&100), Some(&100)); + + sched.add_process(200, 100); + assert_eq!(sched.family_count(), 1); + + sched.remove_process(200); + assert_eq!(sched.family_count(), 1); + + sched.remove_process(100); + assert_eq!(sched.family_count(), 0); + } + + #[test] + fn test_sched_event_active() { + let mut sched = make_scheduler(); + sched.add_process(100, 100); + + // Initially active + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // Sleep -> all idle, but debounced (not yet idle) + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // Wakeup -> stays active + sched.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + } + + #[test] + fn test_sched_event_idle_after_threshold() { + let mut sched = make_scheduler(); + sched.add_process(100, 100); + + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + force_idle_window(&mut sched, 100); + + sched.tick(); + assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + } + + #[test] + fn test_wakeup_cancels_pending_idle() { + let mut sched = make_scheduler(); + sched.add_process(100, 100); + + // Sleep starts the debounce window. + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + // A wakeup before the threshold must cancel the pending idle. + sched.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); + force_idle_window(&mut sched, 100); // would be stale, but window was cleared + sched.tick(); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + } + + #[test] + fn test_multithreaded_active_while_any_thread_runs() { + // One process (tgid 100) with a main thread (tid 100) and a worker (tid 101). + let mut sched = make_scheduler(); + sched.add_process(100, 100); + + // Worker starts running. + sched.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); + // Main (coordinator) thread blocks, but the worker keeps running: + // the family MUST stay ACTIVE (the bug this fix addresses). + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // Worker also blocks -> all threads idle -> after threshold -> IDLE. + sched.on_sched_event(100, 101, SCHED_EVENT_SLEEP); + force_idle_window(&mut sched, 100); + sched.tick(); + assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + + // Worker wakes -> family ACTIVE again (multithreaded wakeup re-activates). + sched.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + } + + #[test] + fn test_family_active_if_any_active() { + let mut sched = make_scheduler(); + sched.add_process(100, 100); + sched.add_process(200, 100); + + // Both active initially + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // One sleeps, family still active + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // Both sleep -> still active due to debounce + sched.on_sched_event(200, 200, SCHED_EVENT_SLEEP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + + // After threshold + force_idle_window(&mut sched, 100); + sched.tick(); + assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + + // One wakes -> immediately active + sched.on_sched_event(200, 200, SCHED_EVENT_WAKEUP); + assert_eq!(sched.family_state(100), Some(SchedState::Active)); + } + + #[test] + fn test_weight_values() { + let config = test_config(); + assert_eq!(config.active_weight, 200); + } + + #[test] + fn test_remove_nonexistent_process() { + let mut sched = make_scheduler(); + sched.remove_process(999); + assert_eq!(sched.family_count(), 0); + } + + #[test] + fn test_sched_event_unknown_pid() { + let mut sched = make_scheduler(); + sched.on_sched_event(999, 999, SCHED_EVENT_WAKEUP); + assert_eq!(sched.family_count(), 0); + } + + #[test] + fn test_multiple_families() { + let mut sched = make_scheduler(); + sched.add_process(100, 100); + sched.add_process(200, 200); + assert_eq!(sched.family_count(), 2); + + sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + force_idle_window(&mut sched, 100); + sched.tick(); + + assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + assert_eq!(sched.family_state(200), Some(SchedState::Active)); + } +} From 4d93cd255e68e736d15a7e157cc585183a55494a Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Fri, 29 May 2026 13:14:57 +0800 Subject: [PATCH 07/10] feat(sight): integrate scheduler into main loop Wire the scheduler end-to-end: dispatch the schedmon probe (enabled only when the scheduler is on), route Event::Sched to on_sched_event, register a process with its Agent family on classification (via lineage find_root) and remove it on exit, and finalize debounced idle transitions from a shared on_idle_tick() called by both the CLI run loop and the FFI driver loop (so the scheduler is not stuck never going idle in embedded mode). Adds the --enable-scheduler CLI flag and a JSON `scheduler` config block (active_weight, idle_threshold_ms, cgroup_root); warns when the config file's enabled value overrides the CLI flag. Verified on kernel 6.6.102 with a multithreaded CPU-bound agent: BURST -> ACTIVE within ~10ms, sustained SLEEP -> IDLE within ~150ms, clean per-cycle transitions, no cgroup leaks, no cpu.idle/weight write errors. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/src/bin/cli/trace.rs | 5 +++ src/agentsight/src/config.rs | 51 ++++++++++++++++++++++++ src/agentsight/src/ffi.rs | 2 + src/agentsight/src/probes/probes.rs | 37 +++++++++++++++++- src/agentsight/src/unified.rs | 60 ++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/agentsight/src/bin/cli/trace.rs b/src/agentsight/src/bin/cli/trace.rs index 8a5dec188..e68864c1b 100644 --- a/src/agentsight/src/bin/cli/trace.rs +++ b/src/agentsight/src/bin/cli/trace.rs @@ -22,6 +22,10 @@ pub struct TraceCommand { #[structopt(long)] pub enable_filewatch: bool, + /// Enable scheduler (idle-burst-idle cgroup CPU weight management) + #[structopt(long)] + pub enable_scheduler: bool, + /// Path to JSON configuration file #[structopt(short, long, default_value = "/etc/agentsight/config.json")] pub config: String, @@ -72,6 +76,7 @@ impl TraceCommand { let config = AgentsightConfig::new() .set_verbose(self.verbose) .set_enable_filewatch(self.enable_filewatch) + .set_enable_scheduler(self.enable_scheduler) .set_config_path(std::path::PathBuf::from(&self.config)); // Create AgentSight (auto-attaches probes and starts polling) diff --git a/src/agentsight/src/config.rs b/src/agentsight/src/config.rs index 1bf37f436..b356acd85 100644 --- a/src/agentsight/src/config.rs +++ b/src/agentsight/src/config.rs @@ -235,6 +235,8 @@ struct JsonFullConfig { encryption: Option, #[serde(default)] runtime: Option, + #[serde(default)] + scheduler: Option, } /// Runtime 动态配置区段(支持热加载,无需重启) @@ -245,6 +247,19 @@ pub struct JsonRuntime { pub sls_logtail_path: Option, } +/// 调度器配置(空闲-突发-空闲 cgroup CPU 权重管理) +#[derive(serde::Deserialize)] +struct JsonScheduler { + #[serde(default)] + enabled: Option, + #[serde(default)] + active_weight: Option, + #[serde(default)] + idle_threshold_ms: Option, + #[serde(default)] + cgroup_root: Option, +} + /// 加密配置:可选公钥(PEM 字符串)或公钥文件路径 #[derive(serde::Deserialize)] struct JsonEncryption { @@ -418,6 +433,10 @@ pub struct AgentsightConfig { pub poll_timeout_ms: u64, /// Enable file watch probe (monitors .jsonl file opens from traced processes) pub enable_filewatch: bool, + /// Enable scheduler (idle-burst-idle cgroup CPU weight management) + pub enable_scheduler: bool, + /// Scheduler configuration + pub scheduler_config: crate::scheduler::SchedulerConfig, /// TCP capture targets for plain HTTP capture (empty = disabled). /// Each entry specifies destination IP, port, or both. pub tcp_targets: Vec, @@ -490,6 +509,8 @@ impl Default for AgentsightConfig { target_uid: None, poll_timeout_ms: DEFAULT_POLL_TIMEOUT_MS, enable_filewatch: false, + enable_scheduler: false, + scheduler_config: crate::scheduler::SchedulerConfig::default(), tcp_targets: Vec::new(), // HTTP/Aggregation defaults @@ -580,6 +601,13 @@ impl AgentsightConfig { self } + /// Set enable_scheduler + pub fn set_enable_scheduler(mut self, enable: bool) -> Self { + self.enable_scheduler = enable; + self.scheduler_config.enabled = enable; + self + } + /// Set connection capacity pub fn set_connection_capacity(mut self, capacity: usize) -> Self { self.connection_capacity = capacity; @@ -643,6 +671,29 @@ impl AgentsightConfig { } } + // Load scheduler config + if let Some(sched) = parsed.scheduler.take() { + if let Some(enabled) = sched.enabled { + if enabled != self.enable_scheduler { + log::warn!( + "config scheduler.enabled={} overrides --enable-scheduler={}", + enabled, self.enable_scheduler + ); + } + self.enable_scheduler = enabled; + self.scheduler_config.enabled = enabled; + } + if let Some(w) = sched.active_weight { + self.scheduler_config.active_weight = w; + } + if let Some(t) = sched.idle_threshold_ms { + self.scheduler_config.idle_threshold_ms = t; + } + if let Some(r) = sched.cgroup_root { + self.scheduler_config.cgroup_root = PathBuf::from(r); + } + } + let (cmdline_rules, https_rules, http_targets) = extract_rules(&parsed); self.cmdline_rules.extend(cmdline_rules); self.https_rules.extend(https_rules); diff --git a/src/agentsight/src/ffi.rs b/src/agentsight/src/ffi.rs index ee0d9433b..d8fd9302c 100644 --- a/src/agentsight/src/ffi.rs +++ b/src/agentsight/src/ffi.rs @@ -680,6 +680,8 @@ fn ffi_background_thread( if sight.try_process().is_none() { // No event available — flush any timed-out pending GenAI events sight.flush_expired_pending_genai(); + // Finalize debounced scheduler idle transitions (same as run()). + sight.on_idle_tick(); std::thread::sleep(std::time::Duration::from_millis(10)); } } diff --git a/src/agentsight/src/probes/probes.rs b/src/agentsight/src/probes/probes.rs index 626e1e2c0..4e99a0f12 100644 --- a/src/agentsight/src/probes/probes.rs +++ b/src/agentsight/src/probes/probes.rs @@ -24,6 +24,7 @@ use super::filewrite::{FileWrite as FileWriteProbe, RawFileWriteEvent}; use super::udpdns::{UdpDns, RawUdpDnsEvent}; use crate::config::TcpTarget; use super::tcpsniff::TcpSniff; +use super::schedmon::{SchedMon, RawSchedEvent}; const POLL_TIMEOUT_MS: u64 = 100; @@ -34,6 +35,7 @@ const EVENT_SOURCE_PROCMON: u32 = 3; const EVENT_SOURCE_FILEWATCH: u32 = 4; const EVENT_SOURCE_FILEWRITE: u32 = 5; const EVENT_SOURCE_UDPDNS: u32 = 6; +const EVENT_SOURCE_SCHED: u32 = 7; /// Unified probe manager that coordinates sslsniff and proctrace /// @@ -57,6 +59,8 @@ pub struct Probes { udpdns: Option, /// TCP sniff probe (captures plain HTTP traffic on configured ports, optional) tcpsniff: Option, + /// Scheduler monitor probe (detects idle/active transitions, optional) + schedmon: Option, /// Shared ring buffer handle (cloned from proctrace) for polling rb_handle: MapHandle, /// Unified event channel - events are converted to Event type inside the poller @@ -70,7 +74,7 @@ impl Probes { /// # Arguments /// * `target_pids` - Initial PIDs to trace (empty means trace all matching UID) /// * `target_uid` - Optional UID filter - pub fn new(target_pids: &[u32], target_uid: Option, enable_filewatch: bool, enable_udpdns: bool, tcp_targets: &[TcpTarget]) -> Result { + pub fn new(target_pids: &[u32], target_uid: Option, enable_filewatch: bool, enable_udpdns: bool, tcp_targets: &[TcpTarget], enable_schedmon: bool) -> Result { // Create proctrace first - it will own the traced_processes map and ring buffer let proctrace = ProcTrace::new_with_target(target_pids, target_uid) .context("failed to create proctrace")?; @@ -126,8 +130,22 @@ impl Probes { None }; + // Optionally create schedmon - detects idle/active transitions for traced processes + let schedmon = if enable_schedmon { + match SchedMon::new_with_maps(&map_handle, &rb_handle) { + Ok(sm) => Some(sm), + Err(e) => { + log::warn!("SchedMon probe unavailable (requires BTF sched tracepoints): {e}"); + None + } + } + } else { + log::info!("SchedMon probe disabled (scheduler not enabled)"); + None + }; + let (event_tx, event_rx) = crossbeam_channel::unbounded(); - + Ok(Self { proctrace, sslsniff, @@ -136,6 +154,7 @@ impl Probes { filewrite, udpdns, tcpsniff, + schedmon, rb_handle, event_tx, event_rx, @@ -166,6 +185,11 @@ impl Probes { tcp.attach() .context("failed to attach tcpsniff")?; } + // Attach schedmon for idle/active transition detection (if enabled) + if let Some(ref mut sm) = self.schedmon { + sm.attach() + .context("failed to attach schedmon")?; + } // sslsniff uses uprobes attached per-process via attach_process() Ok(()) } @@ -193,6 +217,7 @@ impl Probes { let filewatch_event_size = mem::size_of::(); let filewrite_event_size = mem::size_of::(); let udpdns_event_size = mem::size_of::(); + let sched_event_size = mem::size_of::(); let event_tx = self.event_tx.clone(); let stop_flag = Arc::new(AtomicBool::new(false)); @@ -259,6 +284,14 @@ impl Probes { None } } + EVENT_SOURCE_SCHED => { + // Scheduler state event (sleep/wakeup) + if data.len() >= sched_event_size { + super::schedmon::SchedEvent::from_bytes(data).map(Event::Sched) + } else { + None + } + } _ => { // Unknown source - ignore log::warn!("probes: unknown event source {source}"); diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index 780f07389..af3e1e4b5 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -101,6 +101,8 @@ pub struct AgentSight { pending_logtail: Arc>>>, /// Blood lineage tree tracking process parent-child relationships and type classification lineage_tree: Arc>, + /// Idle-burst-idle scheduler for Agent family cgroup CPU weight management + scheduler: Option, } /// GenAI events waiting for session_id resolution via ResponseSessionMapper. @@ -212,7 +214,7 @@ impl AgentSight { // Create probes - agent discovery is handled by AgentScanner via ProcMon events let enable_udpdns = !config.https_rules.is_empty() || !http_domains.is_empty(); let mut probes = - Probes::new(&[], config.target_uid, config.enable_filewatch, enable_udpdns, &tcp_targets).context("Failed to create probes")?; + Probes::new(&[], config.target_uid, config.enable_filewatch, enable_udpdns, &tcp_targets, config.enable_scheduler).context("Failed to create probes")?; // Attach procmon for process monitoring probes.attach().context("Failed to attach probes")?; @@ -441,6 +443,18 @@ impl AgentSight { .ok(); } + let scheduler = if config.enable_scheduler { + log::info!( + "Scheduler enabled: active_weight={}, idle_threshold={}ms, cgroup_root={:?}", + config.scheduler_config.active_weight, + config.scheduler_config.idle_threshold_ms, + config.scheduler_config.cgroup_root, + ); + Some(crate::scheduler::Scheduler::new(config.scheduler_config.clone())) + } else { + None + }; + Ok(AgentSight { probes, parser: Parser::new(), @@ -466,6 +480,7 @@ impl AgentSight { sls_activated, pending_logtail, lineage_tree: Arc::new(std::sync::RwLock::new(crate::lineage::LineageTree::new())), + scheduler, }) } @@ -544,6 +559,14 @@ impl AgentSight { return None; } + // Handle scheduler state events (idle-burst-idle) + if let Event::Sched(ref sched_event) = event { + if let Some(ref mut scheduler) = self.scheduler { + scheduler.on_sched_event(sched_event.tgid, sched_event.tid, sched_event.event_type); + } + return None; + } + // Maintain lineage tree from proctrace exec/exit events if let Event::Proc(ref proc_event) = event { self.update_lineage_from_proc(proc_event); @@ -821,6 +844,16 @@ impl AgentSight { tree.insert(node); } tree.classify(pid, has_agent_mode, matches_agent); + + // Register a classified process with the scheduler's Agent family. + if let Some(ref mut scheduler) = self.scheduler { + let ptype = tree.get(pid).map(|n| n.process_type); + if ptype.is_some_and(|t| t != crate::lineage::ProcessType::Unknown) { + if let Some(root_pid) = tree.find_root(pid) { + scheduler.add_process(pid, root_pid); + } + } + } } } @@ -867,15 +900,28 @@ impl AgentSight { tree.insert(node); tree.classify(header.pid, has_agent_mode, matches_agent); + let ptype = tree.get(header.pid).map(|n| n.process_type); log::debug!( "Lineage: added pid={} ppid={} type={:?}", header.pid, header.ppid, - tree.get(header.pid).map(|n| n.process_type), + ptype, ); + + // Register a classified process with the scheduler's Agent family. + if let Some(ref mut scheduler) = self.scheduler { + if ptype.is_some_and(|t| t != crate::lineage::ProcessType::Unknown) { + if let Some(root_pid) = tree.find_root(header.pid) { + scheduler.add_process(header.pid, root_pid); + } + } + } } } VariableEvent::Exit { header, .. } => { + if let Some(ref mut scheduler) = self.scheduler { + scheduler.remove_process(header.pid); + } if let Ok(mut tree) = self.lineage_tree.write() { if let Some(removed) = tree.remove(header.pid) { log::debug!( @@ -906,6 +952,15 @@ impl AgentSight { self.filewatch_callback = Some(Box::new(callback)); } + /// Idle-loop hook: finalize debounced scheduler transitions. Must be called + /// from every driver loop's idle branch (run() and the FFI loop) so the + /// scheduler is not stuck never transitioning families to idle. + pub fn on_idle_tick(&mut self) { + if let Some(ref mut scheduler) = self.scheduler { + scheduler.tick(); + } + } + /// Handle FileWrite event: extract responseId→sessionId mapping, then call callback fn handle_filewrite_event(&mut self, event: &FileWriteEvent) { log::debug!( @@ -932,6 +987,7 @@ impl AgentSight { self.drain_and_persist_dead_connections(); // Check if config watcher deposited a new LogtailExporter self.check_pending_logtail(); + self.on_idle_tick(); std::thread::sleep(std::time::Duration::from_millis(10)); } } From e7302706b78112527c2cededd7b113e99adc92b6 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Mon, 1 Jun 2026 12:10:26 +0800 Subject: [PATCH 08/10] docs(sight): scope README zero-intrusion claim Distinguishes default zero-intrusion monitoring from opt-in subsystems (e.g. the idle-burst-idle scheduler enabled by --enable-scheduler). Sweeps all three product-summary surfaces under src/agentsight: README.md, README_CN.md (full-width punctuation, plain-language phrasing), and the RPM %description in agentsight.spec.in. NOTE: depends on commit 8a7a036 in this same PR, which introduces the --enable-scheduler flag. Do not split this README change out of #662 or land it ahead of 8a7a036; --enable-scheduler does not exist on main yet. Co-Authored-By: Claude Opus 4.8 --- src/agentsight/README.md | 5 +++-- src/agentsight/README_CN.md | 5 +++-- src/agentsight/agentsight.spec.in | 8 +++++--- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/agentsight/README.md b/src/agentsight/README.md index 4309635a3..f6a300e4e 100644 --- a/src/agentsight/README.md +++ b/src/agentsight/README.md @@ -2,11 +2,12 @@ [中文版](README_CN.md) -eBPF-based observability tool for AI Agents on Linux, providing zero-intrusion monitoring of LLM API calls, token consumption, process behavior, and SSL/TLS traffic. AgentSight is an observability component of [ANOLISA](../../README.md). +eBPF-based observability tool for AI Agents on Linux, providing zero-intrusion monitoring of LLM API calls, token consumption, process behavior, and SSL/TLS traffic. Optional control-plane subsystems (e.g. the idle-burst-idle scheduler) are explicitly opt-in via per-feature flags. AgentSight is an observability component of [ANOLISA](../../README.md). ## Features -- **Zero-Intrusion Monitoring** — eBPF kernel probes capture events without modifying agent code or configurations. +- **Zero-Intrusion Monitoring (default)** — eBPF kernel probes capture events without modifying agent code or configurations. +- **Opt-In Control Plane** — selected eBPF-driven subsystems (e.g. the idle-burst-idle scheduler) write cgroup attributes; off by default, enabled per-feature, e.g. `--enable-scheduler`. - **SSL/TLS Traffic Decryption** — uprobe-based interception of OpenSSL/GnuTLS library calls to capture plaintext HTTP traffic. - **LLM Token Accounting** — Precise token counting with Hugging Face tokenizer support (Qwen series and more). - **AI Agent Auto-Discovery** — Scans `/proc` and monitors `execve` events to dynamically detect running AI agent processes. diff --git a/src/agentsight/README_CN.md b/src/agentsight/README_CN.md index 716f910e2..fcd158759 100644 --- a/src/agentsight/README_CN.md +++ b/src/agentsight/README_CN.md @@ -2,11 +2,12 @@ [English](README.md) -基于 eBPF 的 AI Agent 可观测性工具,在 Linux 系统上提供零侵入式的 LLM API 调用监控、Token 用量统计、进程行为追踪和 SSL/TLS 流量捕获。AgentSight 是 [ANOLISA](../../README_CN.md) 的可观测性组件。 +基于 eBPF 的 AI Agent 可观测性工具,在 Linux 系统上提供零侵入式的 LLM API 调用监控、Token 用量统计、进程行为追踪和 SSL/TLS 流量捕获。可选的控制面子系统(如 idle-burst-idle 调度器)默认关闭,需通过逐项开关显式启用。AgentSight 是 [ANOLISA](../../README_CN.md) 的可观测性组件。 ## 特性 -- **零侵入式监控** — 通过 eBPF 内核探针捕获事件,无需修改 Agent 代码或配置。 +- **零侵入式监控(默认)** — 通过 eBPF 内核探针捕获事件,无需修改 Agent 代码或配置。 +- **可选控制面** — 选定的 eBPF 驱动子系统(如 idle-burst-idle 调度器)会写 cgroup 属性;默认关闭,按需开启,例如 `--enable-scheduler`。 - **SSL/TLS 流量解密** — 基于 uprobe 拦截 OpenSSL/GnuTLS 库调用,捕获加密连接的明文 HTTP 流量。 - **LLM Token 精确计量** — 集成 Hugging Face tokenizer,支持 Qwen 等系列模型的精确 Token 计数。 - **AI Agent 自动发现** — 扫描 `/proc` 并监控 `execve` 事件,动态检测系统上运行的 AI Agent 进程。 diff --git a/src/agentsight/agentsight.spec.in b/src/agentsight/agentsight.spec.in index d70cceb57..174841fa2 100644 --- a/src/agentsight/agentsight.spec.in +++ b/src/agentsight/agentsight.spec.in @@ -19,9 +19,11 @@ BuildRequires: perl-core Requires: elfutils-libelf %description -AgentSight is an eBPF-based AI Agent observability tool that provides zero-intrusion -monitoring for AI Agents running on Linux, capturing SSL/TLS encrypted traffic, -LLM API calls, token consumption, and process behaviors. +AgentSight is an eBPF-based AI Agent observability tool that provides +zero-intrusion monitoring for AI Agents running on Linux, capturing +SSL/TLS encrypted traffic, LLM API calls, token consumption, and process +behaviors. Optional control-plane subsystems (e.g. the idle-burst-idle +scheduler) are explicitly opt-in via per-feature flags. %prep %setup -q From 26c7880f6a172bf68b801a5139184f04cfe67626 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Thu, 4 Jun 2026 21:17:36 +0800 Subject: [PATCH 09/10] refactor(sight): replace cgroup scheduler with pure activity monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cpu.idle toggle is architecturally flawed: userspace round-trip (BPF→daemon→cgroup write) is slower than the kernel's first scheduling decision on wakeup. Worse, cpu.idle=1 sets weight to WEIGHT_IDLEPRIO=3, making the waking agent unable to preempt non-idle tasks until the userspace restores cpu.idle=0. Net effect: acceleration anti-pattern. Keep: BPF schedmon probes (sched_switch/sched_wakeup), per-tid activity tracking state machine, debounced idle/active transitions, unit tests. Remove: all cgroup operations (create/remove/write cpu.idle/cpu.weight/ migrate pid), Drop cleanup, sweep_stale_cgroups, active_weight and cgroup_root config fields. Add: per-family transition metrics (idle_to_active_count, active_to_idle_count, duration tracking) for capacity planning. Revert docs commit that scoped "zero-intrusion" claim — no longer needed since the monitor is purely observational. Co-Authored-By: Claude Opus 4.6 --- src/agentsight/README.md | 5 +- src/agentsight/README_CN.md | 5 +- src/agentsight/agentsight.spec.in | 8 +- src/agentsight/src/config.rs | 28 +- src/agentsight/src/scheduler/mod.rs | 519 ++++++++-------------------- src/agentsight/src/unified.rs | 46 ++- 6 files changed, 180 insertions(+), 431 deletions(-) diff --git a/src/agentsight/README.md b/src/agentsight/README.md index f6a300e4e..4309635a3 100644 --- a/src/agentsight/README.md +++ b/src/agentsight/README.md @@ -2,12 +2,11 @@ [中文版](README_CN.md) -eBPF-based observability tool for AI Agents on Linux, providing zero-intrusion monitoring of LLM API calls, token consumption, process behavior, and SSL/TLS traffic. Optional control-plane subsystems (e.g. the idle-burst-idle scheduler) are explicitly opt-in via per-feature flags. AgentSight is an observability component of [ANOLISA](../../README.md). +eBPF-based observability tool for AI Agents on Linux, providing zero-intrusion monitoring of LLM API calls, token consumption, process behavior, and SSL/TLS traffic. AgentSight is an observability component of [ANOLISA](../../README.md). ## Features -- **Zero-Intrusion Monitoring (default)** — eBPF kernel probes capture events without modifying agent code or configurations. -- **Opt-In Control Plane** — selected eBPF-driven subsystems (e.g. the idle-burst-idle scheduler) write cgroup attributes; off by default, enabled per-feature, e.g. `--enable-scheduler`. +- **Zero-Intrusion Monitoring** — eBPF kernel probes capture events without modifying agent code or configurations. - **SSL/TLS Traffic Decryption** — uprobe-based interception of OpenSSL/GnuTLS library calls to capture plaintext HTTP traffic. - **LLM Token Accounting** — Precise token counting with Hugging Face tokenizer support (Qwen series and more). - **AI Agent Auto-Discovery** — Scans `/proc` and monitors `execve` events to dynamically detect running AI agent processes. diff --git a/src/agentsight/README_CN.md b/src/agentsight/README_CN.md index fcd158759..716f910e2 100644 --- a/src/agentsight/README_CN.md +++ b/src/agentsight/README_CN.md @@ -2,12 +2,11 @@ [English](README.md) -基于 eBPF 的 AI Agent 可观测性工具,在 Linux 系统上提供零侵入式的 LLM API 调用监控、Token 用量统计、进程行为追踪和 SSL/TLS 流量捕获。可选的控制面子系统(如 idle-burst-idle 调度器)默认关闭,需通过逐项开关显式启用。AgentSight 是 [ANOLISA](../../README_CN.md) 的可观测性组件。 +基于 eBPF 的 AI Agent 可观测性工具,在 Linux 系统上提供零侵入式的 LLM API 调用监控、Token 用量统计、进程行为追踪和 SSL/TLS 流量捕获。AgentSight 是 [ANOLISA](../../README_CN.md) 的可观测性组件。 ## 特性 -- **零侵入式监控(默认)** — 通过 eBPF 内核探针捕获事件,无需修改 Agent 代码或配置。 -- **可选控制面** — 选定的 eBPF 驱动子系统(如 idle-burst-idle 调度器)会写 cgroup 属性;默认关闭,按需开启,例如 `--enable-scheduler`。 +- **零侵入式监控** — 通过 eBPF 内核探针捕获事件,无需修改 Agent 代码或配置。 - **SSL/TLS 流量解密** — 基于 uprobe 拦截 OpenSSL/GnuTLS 库调用,捕获加密连接的明文 HTTP 流量。 - **LLM Token 精确计量** — 集成 Hugging Face tokenizer,支持 Qwen 等系列模型的精确 Token 计数。 - **AI Agent 自动发现** — 扫描 `/proc` 并监控 `execve` 事件,动态检测系统上运行的 AI Agent 进程。 diff --git a/src/agentsight/agentsight.spec.in b/src/agentsight/agentsight.spec.in index 174841fa2..d70cceb57 100644 --- a/src/agentsight/agentsight.spec.in +++ b/src/agentsight/agentsight.spec.in @@ -19,11 +19,9 @@ BuildRequires: perl-core Requires: elfutils-libelf %description -AgentSight is an eBPF-based AI Agent observability tool that provides -zero-intrusion monitoring for AI Agents running on Linux, capturing -SSL/TLS encrypted traffic, LLM API calls, token consumption, and process -behaviors. Optional control-plane subsystems (e.g. the idle-burst-idle -scheduler) are explicitly opt-in via per-feature flags. +AgentSight is an eBPF-based AI Agent observability tool that provides zero-intrusion +monitoring for AI Agents running on Linux, capturing SSL/TLS encrypted traffic, +LLM API calls, token consumption, and process behaviors. %prep %setup -q diff --git a/src/agentsight/src/config.rs b/src/agentsight/src/config.rs index b356acd85..a6569f3c2 100644 --- a/src/agentsight/src/config.rs +++ b/src/agentsight/src/config.rs @@ -247,17 +247,13 @@ pub struct JsonRuntime { pub sls_logtail_path: Option, } -/// 调度器配置(空闲-突发-空闲 cgroup CPU 权重管理) +/// Activity monitor config (idle/active detection for observability) #[derive(serde::Deserialize)] struct JsonScheduler { #[serde(default)] enabled: Option, #[serde(default)] - active_weight: Option, - #[serde(default)] idle_threshold_ms: Option, - #[serde(default)] - cgroup_root: Option, } /// 加密配置:可选公钥(PEM 字符串)或公钥文件路径 @@ -433,10 +429,10 @@ pub struct AgentsightConfig { pub poll_timeout_ms: u64, /// Enable file watch probe (monitors .jsonl file opens from traced processes) pub enable_filewatch: bool, - /// Enable scheduler (idle-burst-idle cgroup CPU weight management) + /// Enable activity monitor (agent idle/active state detection via schedmon BPF) pub enable_scheduler: bool, - /// Scheduler configuration - pub scheduler_config: crate::scheduler::SchedulerConfig, + /// Activity monitor configuration + pub activity_config: crate::scheduler::ActivityConfig, /// TCP capture targets for plain HTTP capture (empty = disabled). /// Each entry specifies destination IP, port, or both. pub tcp_targets: Vec, @@ -510,7 +506,7 @@ impl Default for AgentsightConfig { poll_timeout_ms: DEFAULT_POLL_TIMEOUT_MS, enable_filewatch: false, enable_scheduler: false, - scheduler_config: crate::scheduler::SchedulerConfig::default(), + activity_config: crate::scheduler::ActivityConfig::default(), tcp_targets: Vec::new(), // HTTP/Aggregation defaults @@ -601,10 +597,10 @@ impl AgentsightConfig { self } - /// Set enable_scheduler + /// Set enable_scheduler (activity monitor) pub fn set_enable_scheduler(mut self, enable: bool) -> Self { self.enable_scheduler = enable; - self.scheduler_config.enabled = enable; + self.activity_config.enabled = enable; self } @@ -681,16 +677,10 @@ impl AgentsightConfig { ); } self.enable_scheduler = enabled; - self.scheduler_config.enabled = enabled; - } - if let Some(w) = sched.active_weight { - self.scheduler_config.active_weight = w; + self.activity_config.enabled = enabled; } if let Some(t) = sched.idle_threshold_ms { - self.scheduler_config.idle_threshold_ms = t; - } - if let Some(r) = sched.cgroup_root { - self.scheduler_config.cgroup_root = PathBuf::from(r); + self.activity_config.idle_threshold_ms = t; } } diff --git a/src/agentsight/src/scheduler/mod.rs b/src/agentsight/src/scheduler/mod.rs index a3a6d3a60..e83dd0402 100644 --- a/src/agentsight/src/scheduler/mod.rs +++ b/src/agentsight/src/scheduler/mod.rs @@ -1,73 +1,62 @@ -//! Idle-burst-idle scheduler. +//! Agent activity monitor. //! -//! Groups the processes of an Agent family (keyed by the Agent root PID) into a -//! cgroup v2 cpu cgroup and drives `cpu.weight` / `cpu.idle` from the family's -//! aggregate scheduling state: ACTIVE while any member is runnable, IDLE once -//! every member has been blocked for longer than `idle_threshold_ms`. +//! Tracks the scheduling state of Agent process families using per-thread +//! sleep/wakeup events from the schedmon BPF probe. Exports idle/active +//! transitions as observability signals (log + metrics). Does NOT actuate +//! cgroup changes — CPU scheduling policy belongs in the container spec. use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; use std::time::Instant; use crate::probes::schedmon::{SCHED_EVENT_SLEEP, SCHED_EVENT_WAKEUP}; -/// cgroup v2 cpu.weight valid range. -const CPU_WEIGHT_MIN: u32 = 1; -const CPU_WEIGHT_MAX: u32 = 10000; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SchedState { +pub enum ActivityState { Active, Idle, } #[derive(Debug, Clone)] -pub struct SchedulerConfig { +pub struct ActivityConfig { pub enabled: bool, - pub active_weight: u32, pub idle_threshold_ms: u64, - pub cgroup_root: PathBuf, } -impl Default for SchedulerConfig { +impl Default for ActivityConfig { fn default() -> Self { Self { enabled: false, - active_weight: 200, idle_threshold_ms: 50, - cgroup_root: PathBuf::from("/sys/fs/cgroup/agentsight"), } } } +/// Per-family activity metrics. +#[derive(Debug, Clone, Default)] +pub struct FamilyMetrics { + pub idle_to_active_count: u64, + pub active_to_idle_count: u64, + pub last_active_duration_ns: u64, + pub last_idle_duration_ns: u64, +} + struct FamilyState { - cgroup_path: PathBuf, - /// Process ids (tgids) classified into this family, for cgroup membership. member_pids: HashSet, - /// Thread ids currently runnable. The family is ACTIVE while this is - /// non-empty, so a multithreaded process stays ACTIVE as long as any one of - /// its threads is running even if others are blocked. active_tids: HashSet, - state: SchedState, - /// Set to the instant `active_tids` became empty while ACTIVE (pending idle). - /// Cleared as soon as any thread becomes runnable again. The debounced IDLE - /// transition in `tick()` fires only after this has aged past the threshold. + state: ActivityState, idle_since: Option, - cgroup_created: bool, + last_transition: Instant, + metrics: FamilyMetrics, } -pub struct Scheduler { - config: SchedulerConfig, +pub struct ActivityMonitor { + config: ActivityConfig, families: HashMap, pid_to_root: HashMap, } -impl Scheduler { - pub fn new(config: SchedulerConfig) -> Self { - // Sweep cgroups leaked by a previous non-graceful exit (SIGKILL/crash): - // remove now-empty agent-* directories under cgroup_root. Non-empty ones - // (a stale escaped process, or another live instance) are left untouched. - sweep_stale_cgroups(&config.cgroup_root); +impl ActivityMonitor { + pub fn new(config: ActivityConfig) -> Self { Self { config, families: HashMap::new(), @@ -78,44 +67,18 @@ impl Scheduler { pub fn add_process(&mut self, pid: u32, root_pid: u32) { self.pid_to_root.insert(pid, root_pid); - let active_weight = self.config.active_weight; - let family = self.families.entry(root_pid).or_insert_with(|| { - let cgroup_path = self.config.cgroup_root.join(format!("agent-{root_pid}")); - FamilyState { - cgroup_path, - member_pids: HashSet::new(), - active_tids: HashSet::new(), - state: SchedState::Active, - idle_since: None, - cgroup_created: false, - } + let family = self.families.entry(root_pid).or_insert_with(|| FamilyState { + member_pids: HashSet::new(), + active_tids: HashSet::new(), + state: ActivityState::Active, + idle_since: None, + last_transition: Instant::now(), + metrics: FamilyMetrics::default(), }); family.member_pids.insert(pid); - // Seed the process's main thread (tid == tgid == pid) as runnable. Per-tid - // sched events refine the set from here. A family classified late while - // already idle starts ACTIVE and self-corrects on its next wake/sleep - // cycle — the cost is at most one delayed idle window. family.active_tids.insert(pid); family.idle_since = None; - - if !family.cgroup_created { - if let Err(e) = create_cgroup(&family.cgroup_path) { - log::warn!("failed to create cgroup {:?}: {e}", family.cgroup_path); - return; - } - if let Err(e) = write_weight(&family.cgroup_path, active_weight) { - log::warn!("failed to set initial cpu.weight: {e}"); - } - if let Err(e) = write_cpu_idle(&family.cgroup_path, false) { - log::debug!("cpu.idle not available: {e}"); - } - family.cgroup_created = true; - } - - if let Err(e) = migrate_pid(&family.cgroup_path, pid) { - log::debug!("failed to migrate pid {pid} to cgroup: {e}"); - } } pub fn remove_process(&mut self, pid: u32) { @@ -134,25 +97,21 @@ impl Scheduler { if should_remove { if let Some(family) = self.families.remove(&root_pid) { - if family.cgroup_created { - if let Err(e) = remove_cgroup(&family.cgroup_path) { - log::warn!("failed to remove cgroup {:?}: {e}", family.cgroup_path); - } - } + log::debug!( + "activity: family {root_pid} removed (idle_to_active={}, active_to_idle={})", + family.metrics.idle_to_active_count, + family.metrics.active_to_idle_count, + ); } } } - /// Handle a per-thread scheduler event. `tgid` selects the family; `tid` is - /// tracked in the family's runnable set so the family is ACTIVE while any of - /// its threads runs. pub fn on_sched_event(&mut self, tgid: u32, tid: u32, event_type: u8) { let root_pid = match self.pid_to_root.get(&tgid) { Some(&r) => r, None => return, }; - let active_weight = self.config.active_weight; let family = match self.families.get_mut(&root_pid) { Some(f) => f, None => return, @@ -169,63 +128,32 @@ impl Scheduler { } if family.active_tids.is_empty() { - // All members blocked. Start the debounce window if we are not - // already counting; the actual IDLE write happens in tick(). - if family.state == SchedState::Active && family.idle_since.is_none() { + if family.state == ActivityState::Active && family.idle_since.is_none() { family.idle_since = Some(Instant::now()); } return; } - // A member is runnable again: cancel any pending idle. + // A member is runnable: cancel pending idle. family.idle_since = None; - if family.state == SchedState::Idle { - // ACTIVE transition: clear cpu.idle BEFORE setting cpu.weight (the - // kernel rejects weight writes while cpu.idle=1). - family.state = SchedState::Active; - if family.cgroup_created { - if let Err(e) = write_cpu_idle(&family.cgroup_path, false) { - log::debug!("cpu.idle write failed: {e}"); - } - if let Err(e) = write_weight(&family.cgroup_path, active_weight) { - log::warn!("failed to set active weight: {e}"); - } - log::debug!("scheduler: family {root_pid} -> ACTIVE (weight={active_weight})"); - } - } - } - - /// Reap families whose cgroup has no remaining processes (all members exited). - /// This does not depend on process-exit events reaching us — proctrace only - /// emits exit for its own child_pids, so an Agent root detected via AGENT_MODE - /// would otherwise never be torn down. An empty cgroup.procs unambiguously - /// means the whole family is gone (sleeping members are still listed). - fn reap_exited_families(&mut self) { - let dead: Vec = self - .families - .iter() - .filter(|(_, f)| f.cgroup_created && cgroup_is_empty(&f.cgroup_path)) - .map(|(&root, _)| root) - .collect(); - for root in dead { - if let Some(family) = self.families.remove(&root) { - if let Err(e) = remove_cgroup(&family.cgroup_path) { - log::debug!("reap: failed to remove cgroup {:?}: {e}", family.cgroup_path); - } else { - log::debug!("scheduler: reaped exited family {root}"); - } - } - self.pid_to_root.retain(|_, &mut r| r != root); + if family.state == ActivityState::Idle { + let idle_duration = family.last_transition.elapsed(); + family.state = ActivityState::Active; + family.last_transition = Instant::now(); + family.metrics.idle_to_active_count += 1; + family.metrics.last_idle_duration_ns = idle_duration.as_nanos() as u64; + log::debug!( + "activity: family {root_pid} IDLE -> ACTIVE (idle_ms={})", + idle_duration.as_millis(), + ); } } - /// Called periodically from the event loop to finalize debounced idle transitions. pub fn tick(&mut self) { - self.reap_exited_families(); let threshold = std::time::Duration::from_millis(self.config.idle_threshold_ms); for (&root_pid, family) in &mut self.families { - if family.state == SchedState::Idle || !family.active_tids.is_empty() { + if family.state == ActivityState::Idle || !family.active_tids.is_empty() { continue; } match family.idle_since { @@ -233,17 +161,15 @@ impl Scheduler { _ => continue, } - family.state = SchedState::Idle; - if family.cgroup_created { - // Idle via cpu.idle (SCHED_IDLE): the family only runs when nothing - // else wants the CPU. We do NOT write cpu.weight here — the kernel - // rejects cpu.weight writes while cpu.idle=1 and ignores the value - // anyway. cpu.weight is restored on the ACTIVE transition. - if let Err(e) = write_cpu_idle(&family.cgroup_path, true) { - log::warn!("failed to set cpu.idle: {e}"); - } - log::debug!("scheduler: family {root_pid} -> IDLE (cpu.idle=1)"); - } + let active_duration = family.last_transition.elapsed(); + family.state = ActivityState::Idle; + family.last_transition = Instant::now(); + family.metrics.active_to_idle_count += 1; + family.metrics.last_active_duration_ns = active_duration.as_nanos() as u64; + log::debug!( + "activity: family {root_pid} ACTIVE -> IDLE (active_ms={})", + active_duration.as_millis(), + ); } } @@ -251,146 +177,13 @@ impl Scheduler { self.families.len() } - pub fn family_state(&self, root_pid: u32) -> Option { + pub fn family_state(&self, root_pid: u32) -> Option { self.families.get(&root_pid).map(|f| f.state) } -} - -impl Drop for Scheduler { - /// Evacuate every family's processes back to the cgroup v2 root and remove - /// the agent-* directories plus the (now-empty) cgroup_root, so we do not - /// leak cgroups or leave migrated PIDs pinned at idle weight after shutdown. - fn drop(&mut self) { - let mut any_created = false; - for family in self.families.values() { - if family.cgroup_created { - any_created = true; - if let Err(e) = remove_cgroup(&family.cgroup_path) { - log::debug!("failed to remove cgroup {:?} on shutdown: {e}", family.cgroup_path); - } - } - } - if any_created && self.config.cgroup_root.exists() { - let _ = std::fs::remove_dir(&self.config.cgroup_root); - } - } -} - -/// Find the cgroup v2 mount root by walking up from `start` while each directory -/// still exposes `cgroup.subtree_control` (i.e. is itself a cgroup v2 node). -fn cgroup_v2_root(start: &Path) -> Option { - let mut root: Option = None; - let mut cur = Some(start.to_path_buf()); - while let Some(dir) = cur { - if !dir.join("cgroup.subtree_control").exists() { - break; - } - root = Some(dir.clone()); - cur = dir.parent().map(|p| p.to_path_buf()); - } - root -} - -/// Enable the `cpu` controller in every cgroup from the v2 root down to (and -/// including) `target`, so that `target`'s children expose cpu.weight/cpu.idle. -/// Each level must be enabled before its child can inherit the controller, so we -/// walk top-down. Real write failures are surfaced (the controller silently -/// missing is the failure mode that makes the whole scheduler a no-op). -fn enable_cpu_controller(target: &Path) { - // Build the chain target -> ... -> v2 root, then enable top-down. - let mut chain: Vec = Vec::new(); - let mut cur = Some(target.to_path_buf()); - while let Some(dir) = cur { - if !dir.join("cgroup.subtree_control").exists() { - break; - } - chain.push(dir.clone()); - cur = dir.parent().map(|p| p.to_path_buf()); - } - for dir in chain.iter().rev() { - let subtree_ctl = dir.join("cgroup.subtree_control"); - // Writing "+cpu" when already enabled is a no-op; only a genuine failure - // (controller not available at this level, permissions) is worth logging. - if let Err(e) = std::fs::write(&subtree_ctl, "+cpu") { - log::warn!("failed to enable cpu controller at {subtree_ctl:?}: {e}"); - } - } -} - -/// True if the cgroup currently holds no processes. Unreadable cgroup.procs -/// (e.g. the dir is gone) returns false so we never reap on a transient error. -fn cgroup_is_empty(path: &Path) -> bool { - match std::fs::read_to_string(path.join("cgroup.procs")) { - Ok(s) => s.split_whitespace().next().is_none(), - Err(_) => false, - } -} - -/// Remove empty `agent-*` cgroups left under `cgroup_root` by a previous -/// non-graceful exit. Best-effort: rmdir skips non-empty directories, so a live -/// instance's families or an escaped process are never disturbed. -fn sweep_stale_cgroups(cgroup_root: &Path) { - let entries = match std::fs::read_dir(cgroup_root) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("agent-")) - { - if std::fs::remove_dir(&path).is_ok() { - log::debug!("scheduler: swept stale cgroup {path:?}"); - } - } - } -} -fn create_cgroup(path: &Path) -> std::io::Result<()> { - std::fs::create_dir_all(path)?; - // Enable the cpu controller down to the parent so this leaf gets cpu.* files. - if let Some(parent) = path.parent() { - enable_cpu_controller(parent); + pub fn family_metrics(&self, root_pid: u32) -> Option<&FamilyMetrics> { + self.families.get(&root_pid).map(|f| &f.metrics) } - Ok(()) -} - -/// Move any processes still resident in `path` back to the cgroup v2 root, then -/// rmdir it. rmdir fails with EBUSY while a cgroup still holds processes (e.g. -/// fork-without-exec children that were never tracked), so evacuation first. -fn remove_cgroup(path: &Path) -> std::io::Result<()> { - if !path.exists() { - return Ok(()); - } - if let Some(root) = cgroup_v2_root(path) { - let root_procs = root.join("cgroup.procs"); - if let Ok(procs) = std::fs::read_to_string(path.join("cgroup.procs")) { - for pid in procs.split_whitespace() { - // Best-effort: a pid may have exited between read and write. - let _ = std::fs::write(&root_procs, pid); - } - } - } - std::fs::remove_dir(path) -} - -fn write_weight(cgroup_path: &Path, weight: u32) -> std::io::Result<()> { - let clamped = weight.clamp(CPU_WEIGHT_MIN, CPU_WEIGHT_MAX); - if clamped != weight { - log::warn!("cpu.weight {weight} out of range [{CPU_WEIGHT_MIN}, {CPU_WEIGHT_MAX}], clamped to {clamped}"); - } - std::fs::write(cgroup_path.join("cpu.weight"), clamped.to_string()) -} - -fn write_cpu_idle(cgroup_path: &Path, idle: bool) -> std::io::Result<()> { - let val = if idle { "1" } else { "0" }; - std::fs::write(cgroup_path.join("cpu.idle"), val) -} - -fn migrate_pid(cgroup_path: &Path, pid: u32) -> std::io::Result<()> { - std::fs::write(cgroup_path.join("cgroup.procs"), pid.to_string()) } #[cfg(test)] @@ -398,175 +191,147 @@ mod tests { use super::*; use std::time::Duration; - fn test_config() -> SchedulerConfig { - // Unique cgroup_root per scheduler so the (real) filesystem operations in - // sweep/reap/migrate don't race across tests that share pid numbers. - use std::sync::atomic::{AtomicU32, Ordering}; - static SEQ: AtomicU32 = AtomicU32::new(0); - let n = SEQ.fetch_add(1, Ordering::SeqCst); - SchedulerConfig { + fn test_config() -> ActivityConfig { + ActivityConfig { enabled: true, - active_weight: 200, idle_threshold_ms: 10, - cgroup_root: PathBuf::from(format!("/tmp/test-cgroup-agentsight-{n}")), } } - fn make_scheduler() -> Scheduler { - Scheduler::new(test_config()) + fn make_monitor() -> ActivityMonitor { + ActivityMonitor::new(test_config()) } - /// Force a family into the "all members idle for long enough" condition. - fn force_idle_window(sched: &mut Scheduler, root_pid: u32) { - if let Some(family) = sched.families.get_mut(&root_pid) { + fn force_idle_window(monitor: &mut ActivityMonitor, root_pid: u32) { + if let Some(family) = monitor.families.get_mut(&root_pid) { family.idle_since = Some(Instant::now() - Duration::from_millis(20)); } } #[test] fn test_add_remove_process() { - let mut sched = make_scheduler(); + let mut mon = make_monitor(); - sched.add_process(100, 100); - assert_eq!(sched.family_count(), 1); - assert_eq!(sched.pid_to_root.get(&100), Some(&100)); + mon.add_process(100, 100); + assert_eq!(mon.family_count(), 1); + assert_eq!(mon.pid_to_root.get(&100), Some(&100)); - sched.add_process(200, 100); - assert_eq!(sched.family_count(), 1); + mon.add_process(200, 100); + assert_eq!(mon.family_count(), 1); - sched.remove_process(200); - assert_eq!(sched.family_count(), 1); + mon.remove_process(200); + assert_eq!(mon.family_count(), 1); - sched.remove_process(100); - assert_eq!(sched.family_count(), 0); + mon.remove_process(100); + assert_eq!(mon.family_count(), 0); } #[test] fn test_sched_event_active() { - let mut sched = make_scheduler(); - sched.add_process(100, 100); + let mut mon = make_monitor(); + mon.add_process(100, 100); - // Initially active - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); - // Sleep -> all idle, but debounced (not yet idle) - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); - // Wakeup -> stays active - sched.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + mon.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); } #[test] fn test_sched_event_idle_after_threshold() { - let mut sched = make_scheduler(); - sched.add_process(100, 100); + let mut mon = make_monitor(); + mon.add_process(100, 100); - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - force_idle_window(&mut sched, 100); + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + force_idle_window(&mut mon, 100); - sched.tick(); - assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + mon.tick(); + assert_eq!(mon.family_state(100), Some(ActivityState::Idle)); + assert_eq!(mon.family_metrics(100).unwrap().active_to_idle_count, 1); } #[test] fn test_wakeup_cancels_pending_idle() { - let mut sched = make_scheduler(); - sched.add_process(100, 100); - - // Sleep starts the debounce window. - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - // A wakeup before the threshold must cancel the pending idle. - sched.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); - force_idle_window(&mut sched, 100); // would be stale, but window was cleared - sched.tick(); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + let mut mon = make_monitor(); + mon.add_process(100, 100); + + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + mon.on_sched_event(100, 100, SCHED_EVENT_WAKEUP); + force_idle_window(&mut mon, 100); + mon.tick(); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); } #[test] fn test_multithreaded_active_while_any_thread_runs() { - // One process (tgid 100) with a main thread (tid 100) and a worker (tid 101). - let mut sched = make_scheduler(); - sched.add_process(100, 100); - - // Worker starts running. - sched.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); - // Main (coordinator) thread blocks, but the worker keeps running: - // the family MUST stay ACTIVE (the bug this fix addresses). - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); - - // Worker also blocks -> all threads idle -> after threshold -> IDLE. - sched.on_sched_event(100, 101, SCHED_EVENT_SLEEP); - force_idle_window(&mut sched, 100); - sched.tick(); - assert_eq!(sched.family_state(100), Some(SchedState::Idle)); - - // Worker wakes -> family ACTIVE again (multithreaded wakeup re-activates). - sched.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + let mut mon = make_monitor(); + mon.add_process(100, 100); + + mon.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); + + mon.on_sched_event(100, 101, SCHED_EVENT_SLEEP); + force_idle_window(&mut mon, 100); + mon.tick(); + assert_eq!(mon.family_state(100), Some(ActivityState::Idle)); + + mon.on_sched_event(100, 101, SCHED_EVENT_WAKEUP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); + assert_eq!(mon.family_metrics(100).unwrap().idle_to_active_count, 1); } #[test] fn test_family_active_if_any_active() { - let mut sched = make_scheduler(); - sched.add_process(100, 100); - sched.add_process(200, 100); + let mut mon = make_monitor(); + mon.add_process(100, 100); + mon.add_process(200, 100); - // Both active initially - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); - // One sleeps, family still active - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); - // Both sleep -> still active due to debounce - sched.on_sched_event(200, 200, SCHED_EVENT_SLEEP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); + mon.on_sched_event(200, 200, SCHED_EVENT_SLEEP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); - // After threshold - force_idle_window(&mut sched, 100); - sched.tick(); - assert_eq!(sched.family_state(100), Some(SchedState::Idle)); + force_idle_window(&mut mon, 100); + mon.tick(); + assert_eq!(mon.family_state(100), Some(ActivityState::Idle)); - // One wakes -> immediately active - sched.on_sched_event(200, 200, SCHED_EVENT_WAKEUP); - assert_eq!(sched.family_state(100), Some(SchedState::Active)); - } - - #[test] - fn test_weight_values() { - let config = test_config(); - assert_eq!(config.active_weight, 200); + mon.on_sched_event(200, 200, SCHED_EVENT_WAKEUP); + assert_eq!(mon.family_state(100), Some(ActivityState::Active)); } #[test] fn test_remove_nonexistent_process() { - let mut sched = make_scheduler(); - sched.remove_process(999); - assert_eq!(sched.family_count(), 0); + let mut mon = make_monitor(); + mon.remove_process(999); + assert_eq!(mon.family_count(), 0); } #[test] fn test_sched_event_unknown_pid() { - let mut sched = make_scheduler(); - sched.on_sched_event(999, 999, SCHED_EVENT_WAKEUP); - assert_eq!(sched.family_count(), 0); + let mut mon = make_monitor(); + mon.on_sched_event(999, 999, SCHED_EVENT_WAKEUP); + assert_eq!(mon.family_count(), 0); } #[test] fn test_multiple_families() { - let mut sched = make_scheduler(); - sched.add_process(100, 100); - sched.add_process(200, 200); - assert_eq!(sched.family_count(), 2); + let mut mon = make_monitor(); + mon.add_process(100, 100); + mon.add_process(200, 200); + assert_eq!(mon.family_count(), 2); - sched.on_sched_event(100, 100, SCHED_EVENT_SLEEP); - force_idle_window(&mut sched, 100); - sched.tick(); + mon.on_sched_event(100, 100, SCHED_EVENT_SLEEP); + force_idle_window(&mut mon, 100); + mon.tick(); - assert_eq!(sched.family_state(100), Some(SchedState::Idle)); - assert_eq!(sched.family_state(200), Some(SchedState::Active)); + assert_eq!(mon.family_state(100), Some(ActivityState::Idle)); + assert_eq!(mon.family_state(200), Some(ActivityState::Active)); } } diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index af3e1e4b5..bbf851b92 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -101,8 +101,8 @@ pub struct AgentSight { pending_logtail: Arc>>>, /// Blood lineage tree tracking process parent-child relationships and type classification lineage_tree: Arc>, - /// Idle-burst-idle scheduler for Agent family cgroup CPU weight management - scheduler: Option, + /// Agent activity monitor — tracks idle/active transitions (observability only) + activity_monitor: Option, } /// GenAI events waiting for session_id resolution via ResponseSessionMapper. @@ -443,14 +443,12 @@ impl AgentSight { .ok(); } - let scheduler = if config.enable_scheduler { + let activity_monitor = if config.enable_scheduler { log::info!( - "Scheduler enabled: active_weight={}, idle_threshold={}ms, cgroup_root={:?}", - config.scheduler_config.active_weight, - config.scheduler_config.idle_threshold_ms, - config.scheduler_config.cgroup_root, + "Activity monitor enabled: idle_threshold={}ms", + config.activity_config.idle_threshold_ms, ); - Some(crate::scheduler::Scheduler::new(config.scheduler_config.clone())) + Some(crate::scheduler::ActivityMonitor::new(config.activity_config.clone())) } else { None }; @@ -480,7 +478,7 @@ impl AgentSight { sls_activated, pending_logtail, lineage_tree: Arc::new(std::sync::RwLock::new(crate::lineage::LineageTree::new())), - scheduler, + activity_monitor, }) } @@ -559,10 +557,10 @@ impl AgentSight { return None; } - // Handle scheduler state events (idle-burst-idle) + // Handle activity monitor events (idle/active detection) if let Event::Sched(ref sched_event) = event { - if let Some(ref mut scheduler) = self.scheduler { - scheduler.on_sched_event(sched_event.tgid, sched_event.tid, sched_event.event_type); + if let Some(ref mut activity) = self.activity_monitor { + activity.on_sched_event(sched_event.tgid, sched_event.tid, sched_event.event_type); } return None; } @@ -845,12 +843,12 @@ impl AgentSight { } tree.classify(pid, has_agent_mode, matches_agent); - // Register a classified process with the scheduler's Agent family. - if let Some(ref mut scheduler) = self.scheduler { + // Register a classified process with the activity monitor. + if let Some(ref mut activity) = self.activity_monitor { let ptype = tree.get(pid).map(|n| n.process_type); if ptype.is_some_and(|t| t != crate::lineage::ProcessType::Unknown) { if let Some(root_pid) = tree.find_root(pid) { - scheduler.add_process(pid, root_pid); + activity.add_process(pid, root_pid); } } } @@ -908,19 +906,19 @@ impl AgentSight { ptype, ); - // Register a classified process with the scheduler's Agent family. - if let Some(ref mut scheduler) = self.scheduler { + // Register a classified process with the activity monitor. + if let Some(ref mut activity) = self.activity_monitor { if ptype.is_some_and(|t| t != crate::lineage::ProcessType::Unknown) { if let Some(root_pid) = tree.find_root(header.pid) { - scheduler.add_process(header.pid, root_pid); + activity.add_process(header.pid, root_pid); } } } } } VariableEvent::Exit { header, .. } => { - if let Some(ref mut scheduler) = self.scheduler { - scheduler.remove_process(header.pid); + if let Some(ref mut activity) = self.activity_monitor { + activity.remove_process(header.pid); } if let Ok(mut tree) = self.lineage_tree.write() { if let Some(removed) = tree.remove(header.pid) { @@ -952,12 +950,12 @@ impl AgentSight { self.filewatch_callback = Some(Box::new(callback)); } - /// Idle-loop hook: finalize debounced scheduler transitions. Must be called + /// Idle-loop hook: finalize debounced activity transitions. Must be called /// from every driver loop's idle branch (run() and the FFI loop) so the - /// scheduler is not stuck never transitioning families to idle. + /// monitor is not stuck never transitioning families to idle. pub fn on_idle_tick(&mut self) { - if let Some(ref mut scheduler) = self.scheduler { - scheduler.tick(); + if let Some(ref mut activity) = self.activity_monitor { + activity.tick(); } } From c5999453a7b65d9bfe80186d79d107d9e0861158 Mon Sep 17 00:00:00 2001 From: Jiangtian Feng Date: Thu, 4 Jun 2026 21:39:50 +0800 Subject: [PATCH 10/10] fix(sight): unify pid namespace in procmon + lineage leak + reparent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: procmon.bpf.c used get_task_ns_pid() for event->pid but host tgid for ppid — inconsistent in containers. Use host tgid (from bpf_get_current_pid_tgid()) for both, matching proctrace convention. B2: root agent exit only triggered ProcMonEvent::Exit (not proctrace VariableEvent::Exit), so lineage tree was never cleaned. Add lineage_tree.remove() in ProcMonEvent::Exit handler. Also clean activity_monitor.remove_process() in same handler. I1: LineageTree::remove() now reparents children to grandparent instead of orphaning them (mirrors kernel subreaper behavior). Found via workflow kernel-code cross-reference review against cloud-kernel 6.6 branch. Co-Authored-By: Claude Opus 4.6 --- src/agentsight/src/bpf/procmon.bpf.c | 4 ++-- src/agentsight/src/lineage/mod.rs | 12 ++++++++++-- src/agentsight/src/unified.rs | 8 ++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/agentsight/src/bpf/procmon.bpf.c b/src/agentsight/src/bpf/procmon.bpf.c index c5e60d3ac..6161f79a2 100644 --- a/src/agentsight/src/bpf/procmon.bpf.c +++ b/src/agentsight/src/bpf/procmon.bpf.c @@ -44,7 +44,7 @@ int trace_execve_exit(struct trace_event_raw_sys_exit *ctx) // Fill event event->source = EVENT_SOURCE_PROCMON; event->timestamp_ns = ts; - event->pid = get_task_ns_pid(task); + event->pid = pid; event->tid = tid; event->ppid = ppid; event->uid = uid; @@ -78,7 +78,7 @@ int trace_process_exit(void *ctx) // Fill event event->source = EVENT_SOURCE_PROCMON; event->timestamp_ns = ts; - event->pid = current_ns_pid(); + event->pid = pid; event->tid = tid; event->ppid = 0; event->uid = uid; diff --git a/src/agentsight/src/lineage/mod.rs b/src/agentsight/src/lineage/mod.rs index a6b61a710..90cf9cbbf 100644 --- a/src/agentsight/src/lineage/mod.rs +++ b/src/agentsight/src/lineage/mod.rs @@ -90,13 +90,21 @@ impl LineageTree { self.nodes.insert(pid, node); } - /// Remove a node and clean up parent→child links. + /// Remove a node, reparent its children to grandparent, and clean up links. pub fn remove(&mut self, pid: u32) -> Option { let node = self.nodes.remove(&pid)?; - // Remove from parent's children list + // Reparent children to the removed node's parent (mirrors kernel subreaper) + for &child_pid in &node.children { + if let Some(child) = self.nodes.get_mut(&child_pid) { + child.ppid = node.ppid; + } + } + + // Update parent's children list: remove this node, add its children if let Some(parent) = self.nodes.get_mut(&node.ppid) { parent.children.retain(|&c| c != pid); + parent.children.extend(node.children.iter()); } Some(node) diff --git a/src/agentsight/src/unified.rs b/src/agentsight/src/unified.rs index bbf851b92..923d9a608 100644 --- a/src/agentsight/src/unified.rs +++ b/src/agentsight/src/unified.rs @@ -807,6 +807,14 @@ impl AgentSight { let agent_name = agent.agent_info.name.clone(); self.detach_process(*pid, &agent_name); } + // Clean activity monitor (root agents may not be in proctrace child_pids) + if let Some(ref mut activity) = self.activity_monitor { + activity.remove_process(*pid); + } + // Clean lineage tree + if let Ok(mut tree) = self.lineage_tree.write() { + tree.remove(*pid); + } } } }