Skip to main content

agentmux_srv/backend/work_facts/
matching.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `WhoIsWorkingOn`: which other agents' work facts touch a path,
5//! repository, branch or topic
6//! (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md ยง3.2). Pure, so
7//! every rule is tested without a running srv.
8
9use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12
13use super::paths::{clean_relative, is_absolute, normalize_path, normalize_repo, path_matches, relative_to};
14use super::WorkFacts;
15
16/// What the caller asked about. Every field is optional; with none, the
17/// caller's own repository is the question.
18#[derive(Debug, Clone, Default, Deserialize)]
19pub struct WhoQuery {
20    pub path: Option<String>,
21    pub repo: Option<String>,
22    pub branch: Option<String>,
23    pub query: Option<String>,
24}
25
26/// The question after resolving it against the caller's own facts.
27#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
28pub struct Target {
29    /// Repository key (`owner/repo`, or `local:<folder>` without a remote).
30    pub repo: Option<String>,
31    /// Repository-relative path or folder; `""` is the whole repository.
32    pub path: Option<String>,
33    /// An absolute path that is in no known clone: compared as written.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub absolute_path: Option<String>,
36    pub branch: Option<String>,
37    pub query: Option<String>,
38}
39
40/// How one agent's work touches the question. Ordered by strength.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
42#[serde(rename_all = "lowercase")]
43pub enum MatchKind {
44    /// That agent claimed it (`ClaimWork`, [`super::claims`]): its own word
45    /// that it is working on this, so it comes first.
46    Claim,
47    /// The file has uncommitted changes in that agent's clone.
48    Dirty,
49    /// That agent's edit tools changed the file recently.
50    Edited,
51    /// Same repository and branch.
52    Branch,
53    /// Same repository (asked about the repository as a whole).
54    Repo,
55    Goal,
56    Todo,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct WhoMatch {
61    pub kind: MatchKind,
62    pub detail: String,
63    /// When the matched activity happened (Unix ms), where known.
64    pub since_ms: Option<u64>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub struct WhoResult {
69    pub agent: String,
70    pub channel: String,
71    pub status: String,
72    pub goal: Option<String>,
73    pub repo: Option<String>,
74    pub branch: Option<String>,
75    /// When this agent's git facts were taken (Unix ms), so a reader can
76    /// tell how fresh `dirty` and `branch` matches are.
77    pub git_checked_ms: Option<u64>,
78    pub matches: Vec<WhoMatch>,
79}
80
81/// How many file names one match's detail lists before "+N more".
82const DETAIL_FILES: usize = 5;
83
84/// Resolve `q` against the caller's facts (`caller`) and every known clone
85/// (`all`): a path absolute in some clone becomes (repository, relative
86/// path), and the caller's repository is assumed when none is named.
87pub fn resolve(q: &WhoQuery, caller: Option<&WorkFacts>, all: &[WorkFacts]) -> Result<Target, String> {
88    let nonempty = |s: &Option<String>| s.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(str::to_string);
89    let mut t = Target { branch: nonempty(&q.branch), query: nonempty(&q.query), ..Default::default() };
90    let named_repo = match nonempty(&q.repo) {
91        Some(r) => Some(normalize_repo(&r).ok_or_else(|| format!("repo {r:?} is not owner/repo or a remote URL"))?),
92        None => None,
93    };
94    let caller_repo = caller.and_then(WorkFacts::repo_key);
95
96    if let Some(raw) = nonempty(&q.path) {
97        if is_absolute(&raw) {
98            let abs = normalize_path(&raw);
99            // The caller's own clone first, then any other agent's.
100            let owner = caller.into_iter().chain(all.iter()).find_map(|f| {
101                let rel = relative_to(&abs, f.repo_root.as_deref()?)?;
102                Some((f.repo_key()?, rel))
103            });
104            match owner {
105                Some((repo, rel)) => {
106                    t.repo = named_repo.clone().or(Some(repo));
107                    t.path = Some(rel);
108                }
109                None => t.absolute_path = Some(abs),
110            }
111        } else {
112            t.path = Some(clean_relative(&raw));
113        }
114    }
115    if t.repo.is_none() {
116        t.repo = named_repo.or(caller_repo);
117    }
118    if t.path.is_some() && t.repo.is_none() {
119        return Err("path is relative to a repository, but no repository is known: pass `repo`, \
120                    an absolute path, or call this from inside a repository"
121            .to_string());
122    }
123    if t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none() && t.repo.is_none() {
124        return Err("nothing to look for: pass path, repo, branch or query (or call this from inside a \
125                    repository to ask about it)"
126            .to_string());
127    }
128    Ok(t)
129}
130
131/// Lowercase alphanumeric words.
132pub(super) fn words(s: &str) -> HashSet<String> {
133    s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_lowercase).collect()
134}
135
136/// Every word of the query appears in `text`, case-insensitively.
137pub(super) fn mentions(query_words: &HashSet<String>, text: &str) -> bool {
138    !query_words.is_empty() && query_words.is_subset(&words(text))
139}
140
141/// The first few of `files`, then "(+N more)" up to `total`.
142fn list_files(files: &[String], total: usize) -> String {
143    let mut s = files.iter().take(DETAIL_FILES).cloned().collect::<Vec<_>>().join(", ");
144    let shown = files.len().min(DETAIL_FILES);
145    if total > shown {
146        s.push_str(&format!(" (+{} more)", total - shown));
147    }
148    s
149}
150
151/// What `other`'s facts say about `t`, strongest first.
152fn matches_for(t: &Target, other: &WorkFacts) -> Vec<WhoMatch> {
153    let mut out = Vec::new();
154    let same_repo = t.repo.is_some() && other.repo_key() == t.repo;
155    let repo_name = other.repo.clone().or_else(|| other.repo_root.clone()).unwrap_or_default();
156
157    // Files: repository-relative within the same repository, else (a path in
158    // no known clone) the absolute path as written.
159    let file_target: Option<(&str, bool)> = match (&t.path, &t.absolute_path) {
160        (Some(rel), _) if same_repo => Some((rel.as_str(), false)),
161        (None, Some(abs)) => Some((abs.as_str(), true)),
162        _ => None,
163    };
164    let repo_only = t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none();
165    if let Some((target, absolute)) = file_target.or(if repo_only && same_repo { Some(("", false)) } else { None }) {
166        let dirty: Vec<String> = other
167            .dirty_files
168            .iter()
169            .filter(|f| {
170                if absolute {
171                    let abs = format!("{}/{}", other.repo_root.as_deref().unwrap_or(""), f);
172                    path_matches(&normalize_path(&abs), target)
173                } else {
174                    path_matches(f, target)
175                }
176            })
177            .cloned()
178            .collect();
179        let edited: Vec<&super::FactFile> = other
180            .recent_files
181            .iter()
182            .filter(|r| {
183                if absolute {
184                    path_matches(&r.path, target)
185                } else {
186                    same_repo && r.rel.as_deref().is_some_and(|rel| path_matches(rel, target))
187                }
188            })
189            .collect();
190        let last_edit = |file: &str| {
191            edited.iter().filter(|r| r.rel.as_deref() == Some(file) || r.path == file).map(|r| r.ts_ms).max()
192        };
193        if !dirty.is_empty() {
194            // The whole repository has `dirty_count` files even past the
195            // stored list; a narrower target counts what matched.
196            let total = if target.is_empty() && !absolute { other.dirty_count.max(dirty.len()) } else { dirty.len() };
197            out.push(WhoMatch {
198                kind: MatchKind::Dirty,
199                detail: format!("uncommitted in {repo_name}: {}", list_files(&dirty, total)),
200                since_ms: dirty.iter().filter_map(|f| last_edit(f)).max(),
201            });
202        }
203        if !edited.is_empty() {
204            let names: Vec<String> =
205                edited.iter().map(|r| format!("{} ({})", r.rel.as_deref().unwrap_or(&r.path), r.tool)).collect();
206            out.push(WhoMatch {
207                kind: MatchKind::Edited,
208                detail: format!("recently edited: {}", list_files(&names, names.len())),
209                since_ms: edited.iter().map(|r| r.ts_ms).max(),
210            });
211        }
212        if repo_only && out.is_empty() {
213            out.push(WhoMatch {
214                kind: MatchKind::Repo,
215                detail: match &other.branch {
216                    Some(b) => format!("working in {repo_name} on branch {b}"),
217                    None => format!("working in {repo_name}"),
218                },
219                since_ms: None,
220            });
221        }
222    }
223
224    if let Some(branch) = &t.branch {
225        let repo_ok = t.repo.is_none() || same_repo;
226        if repo_ok && other.branch.as_deref() == Some(branch.as_str()) {
227            out.push(WhoMatch {
228                kind: MatchKind::Branch,
229                detail: format!("on branch {branch} in {repo_name}"),
230                since_ms: None,
231            });
232        }
233    }
234
235    if let Some(q) = &t.query {
236        let qw = words(q);
237        if let Some(goal) = other.goal.as_deref().filter(|g| mentions(&qw, g)) {
238            out.push(WhoMatch { kind: MatchKind::Goal, detail: goal.to_string(), since_ms: None });
239        }
240        for todo in other.todos.iter().filter(|td| mentions(&qw, &td.text)).take(3) {
241            out.push(WhoMatch {
242                kind: MatchKind::Todo,
243                detail: format!("[{}] {}", todo.status, todo.text),
244                since_ms: None,
245            });
246        }
247    }
248
249    out.sort_by_key(|m| m.kind);
250    out
251}
252
253/// Every agent in `others` (the caller already left out) whose work touches
254/// `t`, strongest match first, then most recent.
255pub fn who_is_working_on(t: &Target, others: &[WorkFacts]) -> Vec<WhoResult> {
256    let mut results: Vec<WhoResult> = others
257        .iter()
258        .filter_map(|f| {
259            let matches = matches_for(t, f);
260            (!matches.is_empty()).then(|| WhoResult {
261                agent: f.agent.clone(),
262                channel: f.channel.clone(),
263                status: f.status.clone(),
264                goal: f.goal.clone(),
265                repo: f.repo.clone(),
266                branch: f.branch.clone(),
267                git_checked_ms: f.git_checked_ms,
268                matches,
269            })
270        })
271        .collect();
272    sort_results(&mut results);
273    results
274}
275
276/// Strongest match first, then most recent, then by name.
277pub(super) fn sort_results(results: &mut [WhoResult]) {
278    results.sort_by(|a, b| {
279        let strongest = |r: &WhoResult| r.matches.first().map(|m| m.kind);
280        let newest = |r: &WhoResult| r.matches.iter().filter_map(|m| m.since_ms).max();
281        strongest(a)
282            .cmp(&strongest(b))
283            .then_with(|| newest(b).cmp(&newest(a)))
284            .then_with(|| a.agent.cmp(&b.agent))
285    });
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::backend::reactive::progress_watcher::TodoItem;
292    use crate::backend::work_facts::FactFile;
293
294    /// An agent with its own clone of `repo` at `root`.
295    fn agent(name: &str, repo: &str, root: &str, branch: &str) -> WorkFacts {
296        WorkFacts {
297            agent: name.into(),
298            block_id: format!("block-{name}"),
299            channel: "stable".into(),
300            status: "busy".into(),
301            repo: Some(repo.into()),
302            repo_root: Some(root.into()),
303            branch: Some(branch.into()),
304            ..Default::default()
305        }
306    }
307
308    fn edited(root: &str, rel: &str, ts: u64) -> FactFile {
309        FactFile { path: format!("{root}/{rel}"), rel: Some(rel.into()), tool: "Edit".into(), ts_ms: ts }
310    }
311
312    fn q(path: Option<&str>, repo: Option<&str>, branch: Option<&str>, query: Option<&str>) -> WhoQuery {
313        WhoQuery {
314            path: path.map(Into::into),
315            repo: repo.map(Into::into),
316            branch: branch.map(Into::into),
317            query: query.map(Into::into),
318        }
319    }
320
321    fn ask(query: WhoQuery, caller: &WorkFacts, others: &[WorkFacts]) -> Vec<WhoResult> {
322        let mut all = vec![caller.clone()];
323        all.extend_from_slice(others);
324        let t = resolve(&query, Some(caller), &all).unwrap();
325        who_is_working_on(&t, others)
326    }
327
328    /// Two clones of the same remote in different folders match on the
329    /// repository-relative path, whichever spelling the caller used.
330    #[test]
331    fn clones_in_different_folders_match_on_repository_relative_paths() {
332        let me = agent("Me", "o/r", "C:/a/me/r", "main");
333        let mut other = agent("Other", "o/r", "C:/a/other/clone", "feat");
334        other.dirty_files = vec!["crates/srv/lib.rs".into()];
335        other.dirty_count = 1;
336        other.recent_files = vec![edited("C:/a/other/clone", "crates/srv/lib.rs", 50)];
337
338        for path in ["C:/a/me/r/crates/srv/lib.rs", r"C:\a\me\r\crates\srv\lib.rs", "crates/srv/lib.rs"] {
339            let r = ask(q(Some(path), None, None, None), &me, std::slice::from_ref(&other));
340            assert_eq!(r.len(), 1, "{path}");
341            assert_eq!(r[0].agent, "Other");
342            let kinds: Vec<MatchKind> = r[0].matches.iter().map(|m| m.kind).collect();
343            assert_eq!(kinds, vec![MatchKind::Dirty, MatchKind::Edited]);
344            assert_eq!(r[0].matches[0].since_ms, Some(50));
345            assert!(r[0].matches[0].detail.contains("crates/srv/lib.rs"));
346        }
347
348        // A different repository with the same relative path is not a match.
349        let stranger = WorkFacts { dirty_files: vec!["crates/srv/lib.rs".into()], ..agent("S", "o/other", "C:/s", "main") };
350        assert!(ask(q(Some("crates/srv/lib.rs"), None, None, None), &me, &[stranger]).is_empty());
351    }
352
353    #[test]
354    fn a_folder_matches_files_inside_it_by_prefix() {
355        let me = agent("Me", "o/r", "/me", "main");
356        let mut other = agent("Other", "o/r", "/other", "feat");
357        other.dirty_files = vec!["crates/srv/src/a.rs".into(), "docs/x.md".into()];
358        other.recent_files = vec![edited("/other", "crates/srv/src/b.rs", 9)];
359
360        let r = ask(q(Some("crates/srv"), None, None, None), &me, std::slice::from_ref(&other));
361        assert_eq!(r.len(), 1);
362        assert!(r[0].matches[0].detail.contains("crates/srv/src/a.rs"));
363        assert!(!r[0].matches[0].detail.contains("docs/x.md"));
364        assert!(r[0].matches[1].detail.contains("crates/srv/src/b.rs"));
365
366        assert!(ask(q(Some("crates/sr"), None, None, None), &me, &[other]).is_empty(), "a name prefix isn't a folder");
367    }
368
369    #[test]
370    fn query_matches_words_in_goals_and_todos_case_insensitively() {
371        let me = agent("Me", "o/r", "/me", "main");
372        let mut a = agent("A", "o/x", "/a", "main");
373        a.goal = Some("Add the MuxBus allowlist for presence".into());
374        let mut b = agent("B", "o/y", "/b", "main");
375        b.todos = vec![TodoItem { text: "Wire the muxbus Allowlist check".into(), status: "in_progress".into() }];
376        let mut c = agent("C", "o/z", "/c", "main");
377        c.goal = Some("Allowlists everywhere".into());
378
379        let r = ask(q(None, None, None, Some("muxbus ALLOWLIST")), &me, &[a, b, c]);
380        let got: Vec<(&str, MatchKind)> = r.iter().map(|r| (r.agent.as_str(), r.matches[0].kind)).collect();
381        assert_eq!(got, vec![("A", MatchKind::Goal), ("B", MatchKind::Todo)], "whole words, any order, any case");
382        assert_eq!(r[1].matches[0].detail, "[in_progress] Wire the muxbus Allowlist check");
383    }
384
385    #[test]
386    fn branch_matches_within_the_repository() {
387        let me = agent("Me", "o/r", "/me", "main");
388        let a = agent("A", "o/r", "/a", "agenty/x");
389        let b = agent("B", "o/other", "/b", "agenty/x");
390        let r = ask(q(None, None, Some("agenty/x"), None), &me, &[a, b]);
391        assert_eq!(r.len(), 1);
392        assert_eq!(r[0].agent, "A");
393        assert_eq!(r[0].matches[0].kind, MatchKind::Branch);
394    }
395
396    #[test]
397    fn with_nothing_named_the_callers_repository_is_the_question() {
398        let me = agent("Me", "o/r", "/me", "main");
399        let mut a = agent("A", "o/r", "/a", "feat");
400        a.dirty_files = vec!["x.rs".into()];
401        a.dirty_count = 12;
402        let b = agent("B", "o/r", "/b", "fix");
403        let c = agent("C", "o/else", "/c", "main");
404        let r = ask(WhoQuery::default(), &me, &[b, a, c]);
405        let got: Vec<(&str, MatchKind)> = r.iter().map(|r| (r.agent.as_str(), r.matches[0].kind)).collect();
406        assert_eq!(got, vec![("A", MatchKind::Dirty), ("B", MatchKind::Repo)], "ordered by strength");
407        assert!(r[0].matches[0].detail.contains("x.rs (+11 more)"), "{}", r[0].matches[0].detail);
408        assert_eq!(r[1].matches[0].detail, "working in o/r on branch fix");
409    }
410
411    #[test]
412    fn an_explicit_repo_wins_and_bad_input_is_explained() {
413        let me = agent("Me", "o/r", "/me", "main");
414        let mut a = agent("A", "agentmuxai/agentmux", "/a", "feat");
415        a.dirty_files = vec!["src/x.rs".into()];
416        let r = ask(q(Some("src/x.rs"), Some("https://github.com/AgentMuxAI/agentmux.git"), None, None), &me, &[a]);
417        assert_eq!(r.len(), 1);
418
419        assert!(resolve(&q(Some("a.rs"), None, None, None), None, &[]).is_err(), "relative path, no repository");
420        assert!(resolve(&WhoQuery::default(), None, &[]).is_err(), "nothing asked");
421        assert!(resolve(&q(None, Some("not a repo"), None, None), None, &[]).is_err());
422    }
423
424    #[test]
425    fn an_absolute_path_in_another_agents_clone_resolves_through_it() {
426        let me = WorkFacts { agent: "Me".into(), ..Default::default() };
427        let mut a = agent("A", "o/r", "/a/clone", "feat");
428        a.dirty_files = vec!["lib.rs".into()];
429        let b = WorkFacts { dirty_files: vec!["lib.rs".into()], ..agent("B", "o/r", "/b/clone", "main") };
430        let r = ask(q(Some("/a/clone/lib.rs"), None, None, None), &me, &[a, b]);
431        let names: Vec<&str> = r.iter().map(|r| r.agent.as_str()).collect();
432        assert_eq!(names, vec!["A", "B"], "both clones of o/r have lib.rs uncommitted");
433    }
434
435    #[test]
436    fn a_path_in_no_known_clone_is_compared_as_written() {
437        let me = WorkFacts { agent: "Me".into(), ..Default::default() };
438        let other = WorkFacts {
439            agent: "O".into(),
440            recent_files: vec![FactFile { path: "/tmp/notes/a.md".into(), rel: None, tool: "Write".into(), ts_ms: 3 }],
441            ..Default::default()
442        };
443        let r = ask(q(Some("/tmp/notes"), None, None, None), &me, &[other]);
444        assert_eq!(r.len(), 1);
445        assert_eq!(r[0].matches[0].kind, MatchKind::Edited);
446    }
447
448    #[test]
449    fn results_order_by_strength_then_recency() {
450        let me = agent("Me", "o/r", "/me", "main");
451        let mut old = agent("Old", "o/r", "/old", "x");
452        old.recent_files = vec![edited("/old", "a.rs", 10)];
453        let mut new = agent("New", "o/r", "/new", "y");
454        new.recent_files = vec![edited("/new", "a.rs", 20)];
455        let mut dirty = agent("Dirty", "o/r", "/d", "z");
456        dirty.dirty_files = vec!["a.rs".into()];
457        let r = ask(q(Some("a.rs"), None, None, None), &me, &[old, new, dirty]);
458        let names: Vec<&str> = r.iter().map(|r| r.agent.as_str()).collect();
459        assert_eq!(names, vec!["Dirty", "New", "Old"]);
460    }
461}