Skip to main content

agentmux_srv/backend/work_facts/
claims.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Work claims, the pure half (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md
5//! ยง3.4): which claims touch a `WhoIsWorkingOn` question or an edited file,
6//! and how a claim reads. Claims themselves are stored by
7//! `storage::work_claims`; `ClaimWork` and `ReleaseWork` are
8//! `server::work_facts_handlers`.
9
10use crate::backend::storage::work_claims::WorkClaim;
11
12use super::matching::{mentions, sort_results, words, MatchKind, Target, WhoMatch, WhoResult};
13use super::overlap::EditedFile;
14use super::paths::path_matches;
15use super::WorkFacts;
16
17/// How long a claim lasts when the claimer doesn't say.
18pub const DEFAULT_TTL_MINUTES: u64 = 120;
19/// The shortest and longest a claim may last.
20pub const MIN_TTL_MINUTES: u64 = 5;
21pub const MAX_TTL_MINUTES: u64 = 24 * 60;
22
23/// A claim's lifetime in ms: the asked minutes within the bounds, else the default.
24pub fn ttl_ms(minutes: Option<u64>) -> i64 {
25    let m = minutes.unwrap_or(DEFAULT_TTL_MINUTES).clamp(MIN_TTL_MINUTES, MAX_TTL_MINUTES);
26    (m * 60_000) as i64
27}
28
29/// Two repository-relative paths overlap when either holds the other.
30fn paths_overlap(a: &str, b: &str) -> bool {
31    path_matches(a, b) || path_matches(b, a)
32}
33
34/// Whether `c` was made by the caller: by UID when both have one, else by name.
35pub fn is_own(c: &WorkClaim, agent: &str, uid: &str) -> bool {
36    if !c.agent_uid.is_empty() && !uid.is_empty() {
37        return c.agent_uid == uid;
38    }
39    !agent.is_empty() && c.agent.eq_ignore_ascii_case(agent)
40}
41
42/// Whether claim `c` touches the question `t`: a path either holds the
43/// other, the same branch, the repository as a whole, or the query's words
44/// all in the claim's topic, note or path.
45pub fn claim_matches(t: &Target, c: &WorkClaim) -> bool {
46    let same_repo = t.repo.is_some() && c.repo == t.repo;
47    if let (Some(p), Some(cp)) = (&t.path, &c.path) {
48        if same_repo && paths_overlap(cp, p) {
49            return true;
50        }
51    }
52    if let (Some(p), Some(cp)) = (&t.absolute_path, &c.absolute_path) {
53        if paths_overlap(cp, p) {
54            return true;
55        }
56    }
57    let repo_only = t.path.is_none() && t.absolute_path.is_none() && t.branch.is_none() && t.query.is_none();
58    if repo_only && same_repo {
59        return true;
60    }
61    if let (Some(b), Some(cb)) = (&t.branch, &c.branch) {
62        if b == cb && (t.repo.is_none() || c.repo.is_none() || same_repo) {
63            return true;
64        }
65    }
66    if let Some(q) = &t.query {
67        let text = [c.topic.as_deref(), Some(c.note.as_str()), c.path.as_deref(), c.absolute_path.as_deref()]
68            .into_iter()
69            .flatten()
70            .collect::<Vec<_>>()
71            .join(" ");
72        if mentions(&words(q), &text) {
73            return true;
74        }
75    }
76    false
77}
78
79/// What a claim names, for a reader: `crates/srv/src/muxbus in o/r`,
80/// `branch agent5/x in o/r`, `"presence"`, or several of those.
81pub fn describe(c: &WorkClaim) -> String {
82    let whole_repo = c.path.as_deref() == Some("");
83    let mut parts: Vec<String> = Vec::new();
84    if whole_repo {
85        parts.push(format!("all of {}", c.repo.as_deref().unwrap_or("the repository")));
86    } else if let Some(p) = c.path.as_ref().or(c.absolute_path.as_ref()) {
87        parts.push(p.clone());
88    }
89    if let Some(b) = &c.branch {
90        parts.push(format!("branch {b}"));
91    }
92    if let Some(t) = &c.topic {
93        parts.push(format!("\"{t}\""));
94    }
95    // The repository is named after a path in it or a branch of it; a topic
96    // claim's repository is only the one its claimer happened to be in.
97    let in_repo = !whole_repo && (c.path.is_some() || c.branch.is_some());
98    match &c.repo {
99        Some(r) if parts.is_empty() => r.clone(),
100        Some(r) if in_repo => format!("{} in {r}", parts.join(", ")),
101        _ if parts.is_empty() => "(nothing named)".to_string(),
102        _ => parts.join(", "),
103    }
104}
105
106/// "just now", "1 minute ago", "25 minutes ago", "3 hours ago".
107pub fn ago(now_ms: i64, ts_ms: i64) -> String {
108    match (now_ms - ts_ms).max(0) / 60_000 {
109        0 => "just now".to_string(),
110        1 => "1 minute ago".to_string(),
111        m if m < 120 => format!("{m} minutes ago"),
112        m => format!("{} hours ago", m / 60),
113    }
114}
115
116/// One claim as a `WhoIsWorkingOn` match detail:
117/// `claimed crates/srv in o/r 40 minutes ago: "presence work" (expires in 80 minutes)`.
118pub fn detail(c: &WorkClaim, now_ms: i64) -> String {
119    let note = if c.note.trim().is_empty() { String::new() } else { format!(": \"{}\"", c.note.trim()) };
120    let left = ((c.expires_at - now_ms).max(0) + 59_999) / 60_000;
121    format!("claimed {} {}{note} (expires in {left} minutes)", describe(c), ago(now_ms, c.created_at))
122}
123
124/// Add every claim in `claims` that touches `t` to `results`, under the
125/// agent that made it (a result of its own when its facts matched nothing),
126/// then sort as `WhoIsWorkingOn` does. `facts` supplies the status, goal,
127/// repository and branch of a claimer that has no result yet. The caller's
128/// own claims must already be left out.
129pub fn add_claim_matches(
130    results: &mut Vec<WhoResult>,
131    t: &Target,
132    claims: &[WorkClaim],
133    facts: &[WorkFacts],
134    now_ms: i64,
135) {
136    for c in claims.iter().filter(|c| claim_matches(t, c)) {
137        let same = |agent: &str, channel: &str| agent.eq_ignore_ascii_case(&c.agent) && channel == c.channel;
138        let idx = results
139            .iter()
140            .position(|r| same(&r.agent, &r.channel))
141            .or_else(|| results.iter().position(|r| r.agent.eq_ignore_ascii_case(&c.agent)));
142        let idx = match idx {
143            Some(i) => i,
144            None => {
145                let f = facts
146                    .iter()
147                    .find(|f| same(&f.agent, &f.channel))
148                    .or_else(|| facts.iter().find(|f| f.agent.eq_ignore_ascii_case(&c.agent)));
149                results.push(WhoResult {
150                    agent: f.map(|f| f.agent.clone()).unwrap_or_else(|| c.agent.clone()),
151                    channel: f.map(|f| f.channel.clone()).unwrap_or_else(|| c.channel.clone()),
152                    status: f.map(|f| f.status.clone()).unwrap_or_else(|| "not running".to_string()),
153                    goal: f.and_then(|f| f.goal.clone()),
154                    repo: f.and_then(|f| f.repo.clone()).or_else(|| c.repo.clone()),
155                    branch: f.and_then(|f| f.branch.clone()),
156                    git_checked_ms: f.and_then(|f| f.git_checked_ms),
157                    matches: Vec::new(),
158                });
159                results.len() - 1
160            }
161        };
162        let r = &mut results[idx];
163        r.matches.push(WhoMatch {
164            kind: MatchKind::Claim,
165            detail: detail(c, now_ms),
166            since_ms: u64::try_from(c.created_at).ok(),
167        });
168        r.matches.sort_by_key(|m| m.kind);
169    }
170    sort_results(results);
171}
172
173/// The live claims that cover an edited file, by agents other than
174/// `me_agent`: a claimed path in the same repository that holds the file
175/// (or the whole repository).
176pub fn covering<'a>(file: &EditedFile, me_agent: &str, claims: &'a [WorkClaim]) -> Vec<&'a WorkClaim> {
177    claims
178        .iter()
179        .filter(|c| !c.agent.eq_ignore_ascii_case(me_agent))
180        .filter(|c| c.repo.as_deref() == Some(file.repo_key.as_str()))
181        .filter(|c| c.path.as_deref().is_some_and(|p| path_matches(&file.rel, p)))
182        .collect()
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const NOW: i64 = 10 * 60 * 60 * 1000;
190    const MIN: i64 = 60_000;
191
192    fn claim(agent: &str) -> WorkClaim {
193        WorkClaim {
194            id: format!("c-{agent}"),
195            agent: agent.into(),
196            channel: "stable".into(),
197            repo: Some("o/r".into()),
198            note: "presence work".into(),
199            created_at: NOW - 40 * MIN,
200            expires_at: NOW + 80 * MIN,
201            ..Default::default()
202        }
203    }
204
205    fn on_path(agent: &str, path: &str) -> WorkClaim {
206        WorkClaim { path: Some(path.into()), ..claim(agent) }
207    }
208
209    fn target(repo: Option<&str>, path: Option<&str>, branch: Option<&str>, query: Option<&str>) -> Target {
210        Target {
211            repo: repo.map(Into::into),
212            path: path.map(Into::into),
213            branch: branch.map(Into::into),
214            query: query.map(Into::into),
215            ..Default::default()
216        }
217    }
218
219    #[test]
220    fn a_claimed_folder_and_a_file_in_it_match_either_way_round() {
221        let folder = on_path("Agent5", "crates/srv/src/muxbus");
222        assert!(claim_matches(&target(Some("o/r"), Some("crates/srv/src/muxbus/presence.rs"), None, None), &folder));
223        let file = on_path("Agent5", "crates/srv/src/muxbus/presence.rs");
224        assert!(claim_matches(&target(Some("o/r"), Some("crates/srv"), None, None), &file));
225        assert!(!claim_matches(&target(Some("o/r"), Some("crates/mcp"), None, None), &folder));
226        assert!(!claim_matches(&target(Some("o/other"), Some("crates/srv"), None, None), &folder), "another repository");
227        let whole = on_path("Agent5", "");
228        assert!(claim_matches(&target(Some("o/r"), Some("any/file.rs"), None, None), &whole));
229    }
230
231    #[test]
232    fn branch_repository_and_topic_questions_match_their_claims() {
233        let branch = WorkClaim { branch: Some("agent5/x".into()), ..claim("Agent5") };
234        assert!(claim_matches(&target(Some("o/r"), None, Some("agent5/x"), None), &branch));
235        assert!(claim_matches(&target(None, None, Some("agent5/x"), None), &branch));
236        assert!(!claim_matches(&target(Some("o/r"), None, Some("main"), None), &branch));
237
238        assert!(claim_matches(&target(Some("o/r"), None, None, None), &claim("Agent5")), "the repository as a whole");
239
240        let topic = WorkClaim { topic: Some("MuxBus allowlist".into()), repo: None, ..claim("Agent5") };
241        assert!(claim_matches(&target(None, None, None, Some("allowlist muxbus")), &topic));
242        assert!(claim_matches(&target(None, None, None, Some("presence")), &topic), "the note counts too");
243        assert!(!claim_matches(&target(None, None, None, Some("allowlist cognito")), &topic));
244    }
245
246    #[test]
247    fn a_claim_reads_as_what_when_and_why() {
248        assert_eq!(
249            detail(&on_path("Agent5", "crates/srv/src/muxbus"), NOW),
250            "claimed crates/srv/src/muxbus in o/r 40 minutes ago: \"presence work\" (expires in 80 minutes)"
251        );
252        let branch = WorkClaim { branch: Some("agent5/x".into()), note: String::new(), ..claim("Agent5") };
253        assert_eq!(detail(&branch, NOW), "claimed branch agent5/x in o/r 40 minutes ago (expires in 80 minutes)");
254        let topic = WorkClaim { topic: Some("presence".into()), repo: None, ..claim("Agent5") };
255        assert!(detail(&topic, NOW).starts_with("claimed \"presence\" 40 minutes ago"));
256        assert_eq!(describe(&on_path("Agent5", "")), "all of o/r");
257        assert_eq!(describe(&claim("Agent5")), "o/r");
258        assert_eq!(ago(NOW, NOW - 3 * 60 * MIN), "3 hours ago");
259    }
260
261    #[test]
262    fn the_lifetime_defaults_to_two_hours_within_five_minutes_and_a_day() {
263        assert_eq!(ttl_ms(None), 120 * MIN);
264        assert_eq!(ttl_ms(Some(1)), 5 * MIN);
265        assert_eq!(ttl_ms(Some(10_000)), 24 * 60 * MIN);
266        assert_eq!(ttl_ms(Some(45)), 45 * MIN);
267    }
268
269    #[test]
270    fn own_claims_are_known_by_uid_else_by_name() {
271        let mine = WorkClaim { agent_uid: "u1".into(), ..claim("AgentY") };
272        assert!(is_own(&mine, "agenty", ""));
273        assert!(is_own(&mine, "someone", "u1"));
274        assert!(!is_own(&mine, "AgentY", "u2"), "same name, another UID");
275        assert!(!is_own(&claim("Agent5"), "AgentY", "u1"));
276    }
277
278    #[test]
279    fn claims_join_the_claimers_result_or_add_one() {
280        let facts = vec![WorkFacts {
281            agent: "Agent4".into(),
282            channel: "stable".into(),
283            status: "idle".into(),
284            branch: Some("agent4/web".into()),
285            ..Default::default()
286        }];
287        let mut results = vec![WhoResult {
288            agent: "Agent5".into(),
289            channel: "stable".into(),
290            status: "busy".into(),
291            goal: None,
292            repo: Some("o/r".into()),
293            branch: None,
294            git_checked_ms: None,
295            matches: vec![WhoMatch { kind: MatchKind::Edited, detail: "recently edited".into(), since_ms: None }],
296        }];
297        let t = target(Some("o/r"), Some("crates/srv/src/muxbus/a.rs"), None, None);
298        let claims = vec![
299            on_path("Agent5", "crates/srv/src/muxbus"),
300            on_path("Agent4", "crates/srv"),
301            on_path("Gone", "crates"),
302            on_path("Elsewhere", "frontend"),
303        ];
304        add_claim_matches(&mut results, &t, &claims, &facts, NOW);
305        let names: Vec<&str> = results.iter().map(|r| r.agent.as_str()).collect();
306        assert_eq!(names, vec!["Agent4", "Agent5", "Gone"], "claims first, then by name");
307        let five = results.iter().find(|r| r.agent == "Agent5").unwrap();
308        assert_eq!(five.matches.iter().map(|m| m.kind).collect::<Vec<_>>(), vec![MatchKind::Claim, MatchKind::Edited]);
309        let four = results.iter().find(|r| r.agent == "Agent4").unwrap();
310        assert_eq!((four.status.as_str(), four.branch.as_deref()), ("idle", Some("agent4/web")));
311        assert_eq!(results.iter().find(|r| r.agent == "Gone").unwrap().status, "not running");
312    }
313
314    #[test]
315    fn an_edited_file_is_covered_by_other_agents_claims_on_it_or_a_folder_above_it() {
316        let file = EditedFile { repo_key: "o/r".into(), repo: "o/r".into(), rel: "crates/srv/src/muxbus/a.rs".into() };
317        let claims = vec![
318            on_path("Agent5", "crates/srv/src/muxbus"),
319            on_path("AgentY", "crates/srv"),
320            on_path("Agent4", "frontend"),
321            WorkClaim { topic: Some("muxbus".into()), ..claim("Agent2") },
322            WorkClaim { repo: Some("o/other".into()), ..on_path("Agent3", "crates") },
323        ];
324        let got: Vec<&str> = covering(&file, "agenty", &claims).iter().map(|c| c.agent.as_str()).collect();
325        assert_eq!(got, vec!["Agent5"]);
326    }
327}