Skip to main content

agentmux_srv/server/
agent_resources.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// What an agent owns — the one inventory
5// (docs/specs/SPEC_AGENT_TEARDOWN_SINGLE_PATH_2026_10_01.md §6.1).
6//
7// Everything that answers "what does this agent own?" reads it: the teardown
8// (`sagas::agent_teardown`), and — as later phases wire them — the close
9// dialog, the `/quit` summary and Swarm. It covers the sources srv tracks;
10// `Shell()` and `!cmd` processes appear under `processes` since they join the
11// agent's tracker (§6.5). [`survivors`] re-checks a snapshot after a teardown
12// (§6.2 step 8).
13
14use serde::Serialize;
15
16use crate::backend::storage::cron::CronJob;
17use crate::backend::storage::work_queue::{work_state, WorkItem};
18use crate::server::AppState;
19
20/// One process the agent started (the CLI or a descendant), from its
21/// block's process tracker.
22#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
23pub struct ProcessEntry {
24    pub pid: u32,
25    pub command: String,
26    /// Unix ms; 0 if the platform doesn't expose it. With `pid`, identifies
27    /// the process across a later re-check (PID reuse).
28    pub started_at_ms: u64,
29}
30
31/// A `run_in_background` Bash command the agent declared (bashwrap).
32#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
33pub struct BackgroundTaskEntry {
34    pub id: String,
35    pub label: String,
36    pub pid: Option<i64>,
37    /// Unix ms, when bashwrap first saw it.
38    pub started_at_ms: i64,
39    /// Unix ms, the last time bashwrap saw it still running.
40    pub last_seen_ms: i64,
41}
42
43/// A srv-spawned `Shell()` session the agent started.
44#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
45pub struct ShellEntry {
46    pub shell_id: String,
47    pub cmd: String,
48}
49
50/// Everything one agent owns, at one moment.
51#[derive(Clone, Debug, Default, Serialize, PartialEq, Eq)]
52pub struct AgentResources {
53    pub block_id: String,
54    /// The CLI and every process it started that the tracker sees (R1, R2).
55    pub processes: Vec<ProcessEntry>,
56    /// Still-running declared background tasks (the R2 subset, e.g. `task dev`).
57    pub background_tasks: Vec<BackgroundTaskEntry>,
58    /// Running `Shell()` sessions (R3).
59    pub shell_sessions: Vec<ShellEntry>,
60    /// Sub-blocks, e.g. PtyShell drawers (R4).
61    pub sub_blocks: Vec<String>,
62    /// A container agent's container (R6), by name.
63    pub container: Option<String>,
64    /// The agent's working directory (`cmd:cwd`), empty if unset: where the
65    /// Docker Compose projects it may have started live.
66    pub cwd: String,
67}
68
69/// Who an agent is, for the work-queue and cron sources, which are keyed by
70/// identity rather than block.
71#[derive(Clone, Debug, Default)]
72pub struct AgentIdentity {
73    pub agent_id: String,
74    pub uid: Option<String>,
75}
76
77impl AgentIdentity {
78    /// Resolve while the block is still registered: a teardown unregisters it.
79    pub fn of(state: &AppState, block_id: &str) -> Self {
80        let reg = state.reactive_handler.get_agent_by_block(block_id);
81        Self {
82            agent_id: reg.as_ref().map(|a| a.agent_id.clone()).unwrap_or_default(),
83            uid: reg.and_then(|a| a.uid),
84        }
85    }
86
87    /// The name to show: the agent id, else the block id.
88    pub fn label(&self, block_id: &str) -> String {
89        if self.agent_id.is_empty() { block_id.to_string() } else { self.agent_id.clone() }
90    }
91}
92
93/// The process-side inventory of `block_id`: in-memory reads plus two
94/// local-store (SQLite) reads, so call it off the async workers
95/// (`spawn_blocking`), as the teardown does.
96pub fn snapshot(state: &AppState, block_id: &str) -> AgentResources {
97    let mut processes: Vec<ProcessEntry> = state
98        .process_tracker
99        .list_block(block_id)
100        .into_iter()
101        .map(|p| ProcessEntry { pid: p.pid, command: p.command, started_at_ms: p.started_at_ms })
102        .collect();
103    // The Windows tracker doesn't record start times; take them from the OS,
104    // so a later re-check can tell a reused PID from the same process.
105    let missing: Vec<u32> = processes.iter().filter(|p| p.started_at_ms == 0).map(|p| p.pid).collect();
106    if !missing.is_empty() {
107        let times = start_times(&missing);
108        // One that already exited has no start time to record; keeping it
109        // with 0 ("unknown") would let a later check match a reused PID.
110        processes.retain_mut(|p| {
111            if p.started_at_ms == 0 {
112                match times.get(&p.pid) {
113                    Some(t) => p.started_at_ms = *t,
114                    None => return false,
115                }
116            }
117            true
118        });
119    }
120    let background_tasks = state
121        .mstore
122        .background_task_list_for_block(block_id)
123        .unwrap_or_default()
124        .into_iter()
125        .filter(|t| t.ended_at_ms.is_none())
126        .map(|t| BackgroundTaskEntry { id: t.id, label: t.label, pid: t.pid, started_at_ms: t.started_at_ms, last_seen_ms: t.last_seen_ms })
127        .collect();
128    let shell_sessions = state
129        .shell_sessions
130        .list_active()
131        .into_iter()
132        .filter(|s| s.block_id == block_id)
133        .map(|s| ShellEntry { shell_id: s.shell_id, cmd: s.cmd })
134        .collect();
135    let block = state.mstore.get::<crate::backend::obj::Block>(block_id).ok().flatten();
136    let container = block.as_ref().and_then(container_of);
137    let cwd = block
138        .as_ref()
139        .map(|b| crate::backend::obj::meta_get_string(&b.meta, "cmd:cwd", ""))
140        .unwrap_or_default();
141    let sub_blocks = block.and_then(|b| b.subblockids).unwrap_or_default();
142    AgentResources { block_id: block_id.to_string(), processes, background_tasks, shell_sessions, sub_blocks, container, cwd }
143}
144
145/// The container a container agent's block runs in (`agentMode` =
146/// `container`), named from its `agentId` as the launch path names it.
147pub fn container_of(block: &crate::backend::obj::Block) -> Option<String> {
148    use crate::backend::obj::meta_get_string;
149    if meta_get_string(&block.meta, "agentMode", "") != "container" {
150        return None;
151    }
152    let agent_id = meta_get_string(&block.meta, "agentId", "");
153    (!agent_id.is_empty()).then(|| crate::backend::container::container_name_for_slug(&agent_id))
154}
155
156/// Is `container` used by a live controller other than `block_id`'s (the same
157/// agent open in a second pane)? Store reads: call off the async workers.
158pub fn container_shared(state: &AppState, block_id: &str, container: &str) -> bool {
159    crate::backend::blockcontroller::get_all_controllers()
160        .into_keys()
161        .filter(|id| id != block_id)
162        .filter_map(|id| state.mstore.get::<crate::backend::obj::Block>(&id).ok().flatten())
163        .any(|b| container_of(&b).as_deref() == Some(container))
164}
165
166/// Start time (Unix ms, at the OS's one-second resolution) of each of `pids`
167/// that is running now; a PID that isn't running is absent. One targeted OS
168/// query.
169pub fn start_times(pids: &[u32]) -> std::collections::HashMap<u32, u64> {
170    if pids.is_empty() {
171        return Default::default();
172    }
173    let list: Vec<sysinfo::Pid> = pids.iter().map(|p| sysinfo::Pid::from_u32(*p)).collect();
174    let mut sys = sysinfo::System::new();
175    sys.refresh_processes_specifics(
176        sysinfo::ProcessesToUpdate::Some(&list),
177        true,
178        sysinfo::ProcessRefreshKind::nothing(),
179    );
180    sys.processes().iter().map(|(pid, p)| (pid.as_u32(), p.start_time() * 1000)).collect()
181}
182
183/// The processes of a snapshot that are still running: the same PID with the
184/// same start time. A PID the OS has since reused for another process is not
185/// a survivor (spec §8).
186pub fn survivors(before: &[ProcessEntry]) -> Vec<ProcessEntry> {
187    let pids: Vec<u32> = before.iter().map(|p| p.pid).collect();
188    let live = start_times(&pids);
189    before
190        .iter()
191        .filter(|p| live.get(&p.pid).is_some_and(|now| same_start(p.started_at_ms, *now)))
192        .cloned()
193        .collect()
194}
195
196/// The PIDs of `tasks` that are still that task (spec §8, PID reuse).
197///
198/// A process that was already running when bashwrap last saw the task
199/// running IS the task's process: the OS can't reuse a PID while its owner is
200/// alive. So the test is "started no later than `last_seen_ms`", which also
201/// holds for a task that started long after it was declared (a tool call
202/// waiting on a permission prompt). A reused PID started after the task was
203/// last seen, and is left alone.
204pub fn live_background_pids(tasks: &[BackgroundTaskEntry]) -> Vec<u32> {
205    let pids: Vec<u32> = tasks.iter().filter_map(|t| t.pid.and_then(|p| u32::try_from(p).ok())).filter(|p| *p > 1).collect();
206    let live = start_times(&pids);
207    tasks
208        .iter()
209        .filter_map(|t| {
210            let pid = u32::try_from(t.pid?).ok()?;
211            let started = *live.get(&pid)?;
212            // The OS reports start times in whole seconds.
213            (started <= (t.last_seen_ms.max(t.started_at_ms).max(0) as u64) + 1_000).then_some(pid)
214        })
215        .collect()
216}
217
218/// Kill `pid` and every descendant at once (SIGKILL on Unix, TerminateProcess
219/// on Windows), leaves first, after re-checking that `pid` is still the
220/// process started at `started_at_ms` (0 = unknown, judged by PID alone).
221/// One OS snapshot. Returns how many processes it signalled.
222pub fn force_kill_tree(pid: u32, started_at_ms: u64) -> usize {
223    let mut sys = sysinfo::System::new();
224    sys.refresh_processes_specifics(sysinfo::ProcessesToUpdate::All, true, sysinfo::ProcessRefreshKind::nothing());
225    let root = sysinfo::Pid::from_u32(pid);
226    match sys.process(root) {
227        Some(p) if same_start(started_at_ms, p.start_time() * 1000) => {}
228        _ => return 0, // gone, or the PID now belongs to another process
229    }
230    // Breadth-first over parent links, then kill deepest first.
231    let mut tree = vec![root];
232    let mut i = 0;
233    while i < tree.len() {
234        let parent = tree[i];
235        tree.extend(sys.processes().iter().filter(|(_, p)| p.parent() == Some(parent)).map(|(id, _)| *id));
236        i += 1;
237    }
238    tree.iter()
239        .rev()
240        .filter(|id| sys.process(**id).is_some_and(|p| p.kill_with(sysinfo::Signal::Kill).unwrap_or_else(|| p.kill())))
241        .count()
242}
243
244/// `recorded` 0 means the start time wasn't known; the OS reports seconds.
245fn same_start(recorded_ms: u64, now_ms: u64) -> bool {
246    recorded_ms == 0 || recorded_ms.abs_diff(now_ms) <= 1000
247}
248
249/// Does a claim held by (`claimed_by`, `claimed_by_uid`) belong to the agent?
250/// Same holder rule as the store's release (`HOLDER_BY_UID`): by UID when
251/// both sides have one, else by name.
252pub fn holds_claim(claimed_by: &str, claimed_by_uid: &str, agent_id: &str, uid: Option<&str>) -> bool {
253    match uid.filter(|u| !u.is_empty()) {
254        Some(u) if !claimed_by_uid.is_empty() => claimed_by_uid == u,
255        _ => !agent_id.is_empty() && claimed_by == agent_id,
256    }
257}
258
259/// Does a cron job aimed at (`target`, `target_uid`) deliver to the agent?
260/// By UID when the job has one, else by name (case-insensitive, like agent
261/// addressing).
262pub fn cron_targets(target: &str, target_uid: &str, agent_id: &str, uid: Option<&str>) -> bool {
263    match uid.filter(|u| !u.is_empty()) {
264        Some(u) if !target_uid.is_empty() => target_uid == u,
265        _ => !agent_id.is_empty() && target.eq_ignore_ascii_case(agent_id),
266    }
267}
268
269/// The agent's claimed work items (R7).
270pub(crate) fn claims_held_by<'a>(items: &'a [WorkItem], agent_id: &str, uid: Option<&str>) -> Vec<&'a WorkItem> {
271    items
272        .iter()
273        .filter(|w| w.state == work_state::CLAIMED && holds_claim(&w.claimed_by, &w.claimed_by_uid, agent_id, uid))
274        .collect()
275}
276
277/// Names of the cron jobs targeting the agent (R8).
278pub(crate) fn crons_targeting(jobs: &[CronJob], agent_id: &str, uid: Option<&str>) -> Vec<String> {
279    jobs.iter()
280        .filter(|j| cron_targets(&j.target, &j.target_uid, agent_id, uid))
281        .map(|j| j.name.clone())
282        .collect()
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn a_live_process_survives_and_a_reused_pid_does_not() {
291        let me = std::process::id();
292        let started = *start_times(&[me]).get(&me).expect("this process is running");
293        assert!(started > 0);
294        let entry = |started_at_ms| ProcessEntry { pid: me, command: "test".into(), started_at_ms };
295        assert_eq!(survivors(&[entry(started)]).len(), 1, "same pid, same start: still running");
296        assert_eq!(survivors(&[entry(0)]).len(), 1, "unknown start time: judged by pid");
297        assert!(survivors(&[entry(started - 60_000)]).is_empty(), "same pid, other start: a reused pid");
298    }
299
300    #[test]
301    fn a_background_task_pid_counts_only_while_it_is_that_task() {
302        let me = std::process::id();
303        let started = *start_times(&[me]).get(&me).unwrap() as i64;
304        let task = |started_at_ms, last_seen_ms| BackgroundTaskEntry {
305            id: "t".into(),
306            label: "task dev".into(),
307            pid: Some(me as i64),
308            started_at_ms,
309            last_seen_ms,
310        };
311        assert_eq!(live_background_pids(&[task(started + 500, started + 500)]), vec![me], "seen just after it started");
312        assert_eq!(
313            live_background_pids(&[task(started - 60_000, started + 10_000)]),
314            vec![me],
315            "declared a minute before it started (a permission prompt), seen running since: still the task"
316        );
317        assert!(
318            live_background_pids(&[task(started - 60_000, started - 30_000)]).is_empty(),
319            "last seen before this process started: the PID was reused"
320        );
321        let no_pid = BackgroundTaskEntry { pid: None, ..task(started, started) };
322        assert!(live_background_pids(&[no_pid]).is_empty());
323    }
324
325    /// The verify step's forced kill takes the whole tree, and a child that
326    /// would ignore a polite signal still dies.
327    #[test]
328    fn force_kill_tree_takes_the_child_too() {
329        #[cfg(windows)]
330        let mut parent = std::process::Command::new("cmd").args(["/C", "ping -n 30 127.0.0.1 > nul"]).spawn().unwrap();
331        #[cfg(unix)]
332        let mut parent = std::process::Command::new("sh").args(["-c", "trap '' TERM; sleep 30 & wait"]).spawn().unwrap();
333        let pid = parent.id();
334        // Let the shell start its child.
335        let child_of = |pid: u32| {
336            let mut sys = sysinfo::System::new();
337            sys.refresh_processes_specifics(sysinfo::ProcessesToUpdate::All, true, sysinfo::ProcessRefreshKind::nothing());
338            sys.processes()
339                .iter()
340                .find(|(_, p)| p.parent() == Some(sysinfo::Pid::from_u32(pid)))
341                .map(|(id, _)| id.as_u32())
342        };
343        let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
344        let mut child = None;
345        while child.is_none() && std::time::Instant::now() < until {
346            std::thread::sleep(std::time::Duration::from_millis(100));
347            child = child_of(pid);
348        }
349        let child = child.expect("the shell started a child");
350
351        assert_eq!(force_kill_tree(pid, 60_000), 0, "a start time that doesn't match: not this process, nothing killed");
352        // Children go first; a shell whose child died may exit on its own
353        // before its turn, so count >= 1 and check both are gone below.
354        assert!(force_kill_tree(pid, 0) >= 1);
355        let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
356        while (start_times(&[pid, child]).len() > 0) && std::time::Instant::now() < until {
357            std::thread::sleep(std::time::Duration::from_millis(50));
358        }
359        let _ = parent.wait();
360        assert!(start_times(&[pid, child]).is_empty(), "the parent and its child are gone");
361    }
362
363    #[test]
364    fn only_a_container_agent_has_a_container() {
365        let block = |meta: &[(&str, &str)]| crate::backend::obj::Block {
366            meta: meta.iter().map(|(k, v)| (k.to_string(), serde_json::json!(v))).collect(),
367            ..Default::default()
368        };
369        assert_eq!(
370            container_of(&block(&[("agentMode", "container"), ("agentId", "lark")])).as_deref(),
371            Some(crate::backend::container::container_name_for_slug("lark").as_str())
372        );
373        assert_eq!(container_of(&block(&[("agentMode", "host"), ("agentId", "lark")])), None);
374        assert_eq!(container_of(&block(&[("agentMode", "container")])), None, "no agentId, no name");
375    }
376
377    #[test]
378    fn an_exited_process_is_not_a_survivor() {
379        #[cfg(windows)]
380        let mut child = std::process::Command::new("cmd").args(["/C", "exit 0"]).spawn().unwrap();
381        #[cfg(unix)]
382        let mut child = std::process::Command::new("sh").args(["-c", "exit 0"]).spawn().unwrap();
383        let pid = child.id();
384        child.wait().unwrap();
385        drop(child);
386        assert!(survivors(&[ProcessEntry { pid, command: "gone".into(), started_at_ms: 0 }]).is_empty());
387    }
388}