Skip to main content

agentmux_srv/backend/work_facts/
overlap_notes.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Overlap notes, the runtime half
5//! (docs/specs/SPEC_AGENT_OVERLAP_AWARENESS_2026_10_10.md §3.3, file half).
6//!
7//! The progress watcher hands over each sweep's new edits
8//! ([`on_new_edits`]) and returns at once: the check runs on its own task.
9//! There, every agent's work facts on this computer are gathered (this srv's
10//! directly, other channels' through [`super::cross_channel`] with its
11//! timeouts), each edit is matched ([`super::overlap`]), and a match becomes
12//! one `[AgentMux]` system note to the agent that edited, rate-limited by
13//! [`super::overlap::RateLimiter`]. Delivery is best-effort: a failure is
14//! logged, never retried, and never reaches the watcher.
15//!
16//! The PR half (§3.3): when the watcher sees an agent open a PR
17//! ([`on_new_prs`]), the files its branch changes are compared with other
18//! agents' uncommitted changes, recent edits and claims, and with the files
19//! committed on their own branches of the same repository on this computer
20//! (usually their open PRs). Local facts only: srv has no GitHub identity.
21//! Open PRs from other computers are `WhoIsWorkingOn`'s, through the agent's
22//! own `gh-agent`. One note per (pane, branch) per 2 hours.
23//!
24//! Off with the setting `agent:overlapnotes: false`.
25
26use std::collections::HashMap;
27use std::future::Future;
28use std::sync::{Arc, OnceLock};
29
30use crate::backend::storage::store::Store;
31use crate::backend::storage::work_claims::WorkClaim;
32use crate::backend::wconfig::ConfigState;
33
34use super::overlap::{self, Decision, RateLimiter};
35use super::{collect_local, cross_channel, WorkFacts};
36
37/// The setting that turns overlap notes off for this install.
38pub const SETTING: &str = "agent:overlapnotes";
39
40/// One edit the progress watcher saw happen live.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct NewEdit {
43    pub agent_id: String,
44    pub block_id: String,
45    /// As the edit tool named it.
46    pub path: String,
47}
48
49/// An agent the progress watcher saw open a PR (`gh-agent pr create`,
50/// `gh pr create`), live.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct NewPr {
53    pub agent_id: String,
54    pub block_id: String,
55    /// `--head` (a branch, or `owner:branch`), when the command named one;
56    /// else the branch checked out.
57    pub head: Option<String>,
58    /// `--base`, when named; else the branch's `gh-merge-base`, else the
59    /// default branch.
60    pub base: Option<String>,
61}
62
63/// One PR note per (pane, branch) in this long.
64const PR_NOTE_EVERY_MS: u64 = 2 * 60 * 60 * 1000;
65/// Other clones whose branch files are read, at most, per PR.
66const MAX_BRANCH_CLONES: usize = 8;
67
68/// What the check needs from the running srv, set once it exists.
69struct Context {
70    store: Arc<Store>,
71    /// The identity store, where every channel's work claims are.
72    claims: Arc<Store>,
73    config: Arc<ConfigState>,
74    client: reqwest::Client,
75    own_url: String,
76}
77
78static CONTEXT: OnceLock<Context> = OnceLock::new();
79
80fn limiter() -> &'static parking_lot::Mutex<RateLimiter> {
81    static LIMITER: std::sync::LazyLock<parking_lot::Mutex<RateLimiter>> = std::sync::LazyLock::new(Default::default);
82    &LIMITER
83}
84
85/// Turn the notes on for this srv. Until this is called (and in tests),
86/// [`on_new_edits`] does nothing.
87pub fn install(
88    store: Arc<Store>,
89    claims: Arc<Store>,
90    config: Arc<ConfigState>,
91    client: reqwest::Client,
92    own_url: String,
93) {
94    let _ = CONTEXT.set(Context { store, claims, config, client, own_url });
95}
96
97/// Whether the notes are on (`agent:overlapnotes`, default on).
98pub fn enabled(extra: &HashMap<String, serde_json::Value>) -> bool {
99    extra.get(SETTING).and_then(|v| v.as_bool()).unwrap_or(true)
100}
101
102/// The edits worth checking now: none when the notes are off, else those
103/// whose (pane, file) wasn't checked in the last minute.
104fn admit(
105    edits: Vec<NewEdit>,
106    extra: &HashMap<String, serde_json::Value>,
107    limiter: &mut RateLimiter,
108    now_ms: u64,
109) -> Vec<NewEdit> {
110    if !enabled(extra) {
111        return Vec::new();
112    }
113    edits.into_iter().filter(|e| limiter.should_check(&e.block_id, &e.path, now_ms)).collect()
114}
115
116/// Run `check` on its own task and return at once, so the caller (the
117/// watcher's loop) never waits on fetching facts or delivering a note.
118fn dispatch<F, Fut>(edits: Vec<NewEdit>, check: F)
119where
120    F: FnOnce(Vec<NewEdit>) -> Fut,
121    Fut: Future<Output = ()> + Send + 'static,
122{
123    if edits.is_empty() {
124        return;
125    }
126    match tokio::runtime::Handle::try_current() {
127        Ok(rt) => {
128            rt.spawn(check(edits));
129        }
130        Err(_) => tracing::debug!("overlap notes: no runtime; edits not checked"),
131    }
132}
133
134/// The progress watcher's hand-off: this sweep's live edits. Returns at
135/// once; see the module doc.
136pub fn on_new_edits(edits: Vec<NewEdit>) {
137    if edits.is_empty() {
138        return;
139    }
140    let Some(ctx) = CONTEXT.get() else { return };
141    let extra = ctx.config.get_settings().extra;
142    let now = agentmux_common::time::now_ms_u64();
143    let edits = admit(edits, &extra, &mut limiter().lock(), now);
144    dispatch(edits, |edits| check_and_notify(ctx, edits));
145}
146
147/// Gather every agent's facts, match each edit, and deliver what the rate
148/// limits allow.
149async fn check_and_notify(ctx: &'static Context, edits: Vec<NewEdit>) {
150    let handler = crate::backend::reactive::get_global_handler();
151    let regs = handler.list_agents();
152    let channel = cross_channel::own_channel();
153    let store = ctx.store.clone();
154    let regs_for_facts = regs.clone();
155    let local = tokio::task::spawn_blocking(move || collect_local(&store, &regs_for_facts, &channel));
156    let claims_store = ctx.claims.clone();
157    let claims = tokio::task::spawn_blocking(move || {
158        claims_store.work_claims_live(agentmux_common::time::now_ms()).unwrap_or_default()
159    });
160    let (local, claims, (cross, _)) = tokio::join!(local, claims, cross_channel::fetch(&ctx.client, &ctx.own_url));
161    let mut all: Vec<WorkFacts> = local.unwrap_or_default();
162    all.extend(cross);
163    let claims = claims.unwrap_or_default();
164    let now = agentmux_common::time::now_ms_u64();
165    for edit in edits {
166        let Some(text) = note_for(&edit, &all, &claims, &mut limiter().lock(), now) else { continue };
167        // By UID when the pane has one, so a name two panes share still
168        // reaches the one that edited.
169        let target = regs
170            .iter()
171            .find(|r| r.block_id == edit.block_id)
172            .and_then(|r| r.uid.clone())
173            .unwrap_or_else(|| edit.agent_id.clone());
174        if let Err(e) = handler.deliver_system_note(&target, &text) {
175            tracing::info!(agent = %edit.agent_id, error = %e, "overlap note not delivered");
176        }
177    }
178}
179
180/// The note (if any) for one edit, given every agent's facts and the live
181/// work claims.
182fn note_for(
183    edit: &NewEdit,
184    all: &[WorkFacts],
185    claims: &[WorkClaim],
186    limiter: &mut RateLimiter,
187    now_ms: u64,
188) -> Option<String> {
189    let me = all.iter().find(|f| f.block_id == edit.block_id);
190    let file = overlap::locate(&edit.path, me, all)?;
191    let found = overlap::overlaps(&file, &edit.agent_id, all, now_ms);
192    let found = overlap::with_claims(found, &file, &edit.agent_id, claims);
193    if found.is_empty() {
194        return None;
195    }
196    match limiter.decide(&edit.agent_id, &file, &found, now_ms)? {
197        Decision::Note(t) | Decision::Summary(t) => Some(t),
198    }
199}
200
201/// The progress watcher's hand-off: PRs being opened this sweep. Returns at
202/// once; the check runs on its own task.
203pub fn on_new_prs(prs: Vec<NewPr>) {
204    if prs.is_empty() {
205        return;
206    }
207    let Some(ctx) = CONTEXT.get() else { return };
208    if !enabled(&ctx.config.get_settings().extra) {
209        return;
210    }
211    match tokio::runtime::Handle::try_current() {
212        Ok(rt) => {
213            for pr in prs {
214                rt.spawn(check_pr(ctx, pr));
215            }
216        }
217        Err(_) => tracing::debug!("overlap notes: no runtime; PRs not checked"),
218    }
219}
220
221fn pr_notes_sent() -> &'static parking_lot::Mutex<HashMap<(String, String), u64>> {
222    static SENT: OnceLock<parking_lot::Mutex<HashMap<(String, String), u64>>> = OnceLock::new();
223    SENT.get_or_init(Default::default)
224}
225
226/// Whether (pane, branch) may get a PR note now, and if so, record it.
227fn admit_pr_note(sent: &mut HashMap<(String, String), u64>, block_id: &str, branch: &str, now_ms: u64) -> bool {
228    sent.retain(|_, at| now_ms.saturating_sub(*at) < PR_NOTE_EVERY_MS);
229    let key = (block_id.to_string(), branch.to_string());
230    if sent.contains_key(&key) {
231        return false;
232    }
233    sent.insert(key, now_ms);
234    true
235}
236
237async fn check_pr(ctx: &'static Context, pr: NewPr) {
238    let handler = crate::backend::reactive::get_global_handler();
239    let regs = handler.list_agents();
240    let channel = cross_channel::own_channel();
241    let store = ctx.store.clone();
242    let regs_for_facts = regs.clone();
243    let local = tokio::task::spawn_blocking(move || collect_local(&store, &regs_for_facts, &channel));
244    let claims_store = ctx.claims.clone();
245    let claims = tokio::task::spawn_blocking(move || {
246        claims_store.work_claims_live(agentmux_common::time::now_ms()).unwrap_or_default()
247    });
248    let (local, claims, (cross, _)) = tokio::join!(local, claims, cross_channel::fetch(&ctx.client, &ctx.own_url));
249    let mut all: Vec<WorkFacts> = local.unwrap_or_default();
250    all.extend(cross);
251    let claims = claims.unwrap_or_default();
252
253    let Some(me) = all.iter().find(|f| f.block_id == pr.block_id).cloned() else { return };
254    // The PR's own head and base: `--head`/`--base`, else the checked-out
255    // branch and its `gh-merge-base`, else the default branch.
256    let head = pr.head.as_deref().map(|h| h.rsplit(':').next().unwrap_or(h).to_string()).or(me.branch.clone());
257    let (Some(root), Some(repo_key), Some(branch)) = (me.repo_root.clone(), me.repo_key(), head) else {
258        return;
259    };
260    let base = match pr.base.clone() {
261        Some(b) => Some(b),
262        None => super::git::merge_base_setting(&root, &branch).await,
263    };
264    let files = super::git::branch_files(&root, base.as_deref(), &branch).await;
265    if files.is_empty() {
266        return;
267    }
268    // Other clones of the same repository, on a branch of their own.
269    let mut theirs: Vec<(String, String, String)> = Vec::new();
270    for f in &all {
271        if theirs.len() >= MAX_BRANCH_CLONES {
272            break;
273        }
274        let (Some(their_root), Some(their_branch)) = (f.repo_root.as_deref(), f.branch.as_deref()) else { continue };
275        let same_repo = f.repo_key().as_deref() == Some(repo_key.as_str());
276        if !same_repo || f.agent.eq_ignore_ascii_case(&me.agent) || their_root == root || their_branch == branch {
277            continue;
278        }
279        if theirs.iter().any(|(_, r, _)| r == their_root) {
280            continue;
281        }
282        theirs.push((f.agent.clone(), their_root.to_string(), their_branch.to_string()));
283    }
284    let mut branch_files = Vec::new();
285    for (agent, their_root, their_branch) in theirs {
286        let changed = super::git::branch_files(&their_root, None, &their_branch).await;
287        branch_files.push((agent, their_branch, changed));
288    }
289    let repo = me.repo.clone().unwrap_or_else(|| root.clone());
290    let now = agentmux_common::time::now_ms_u64();
291    let found = pr_overlaps(&me.agent, &repo_key, &repo, &files, &all, &claims, &branch_files, now);
292    if found.is_empty() || !admit_pr_note(&mut pr_notes_sent().lock(), &pr.block_id, &branch, now) {
293        return;
294    }
295    let text = overlap::pr_note_text(&branch, &found);
296    let target = regs
297        .iter()
298        .find(|r| r.block_id == pr.block_id)
299        .and_then(|r| r.uid.clone())
300        .unwrap_or_else(|| pr.agent_id.clone());
301    if let Err(e) = handler.deliver_system_note(&target, &text) {
302        tracing::info!(agent = %pr.agent_id, error = %e, "PR overlap note not delivered");
303    }
304}
305
306/// Every other agent working on the PR's `files`: how, per agent, strongest
307/// first (uncommitted, edited, claimed, on its branch). `branch_files`: other
308/// agents' (name, branch, files their branch changes).
309#[allow(clippy::too_many_arguments)]
310fn pr_overlaps(
311    me_agent: &str,
312    repo_key: &str,
313    repo: &str,
314    files: &[String],
315    all: &[WorkFacts],
316    claims: &[WorkClaim],
317    branch_files: &[(String, String, Vec<String>)],
318    now_ms: u64,
319) -> Vec<overlap::PrOverlap> {
320    #[derive(Default)]
321    struct Ways {
322        name: String,
323        dirty: Vec<String>,
324        edited: Vec<String>,
325        claims: Vec<String>,
326        branch: Option<(String, Vec<String>)>,
327    }
328    let mut by_agent: Vec<Ways> = Vec::new();
329    fn ways<'a>(by_agent: &'a mut Vec<Ways>, name: &str) -> &'a mut Ways {
330        if let Some(i) = by_agent.iter().position(|w| w.name.eq_ignore_ascii_case(name)) {
331            return &mut by_agent[i];
332        }
333        by_agent.push(Ways { name: name.to_string(), ..Default::default() });
334        by_agent.last_mut().expect("just pushed")
335    }
336    for rel in files {
337        let file = overlap::EditedFile { repo_key: repo_key.to_string(), repo: repo.to_string(), rel: rel.clone() };
338        let found = overlap::with_claims(overlap::overlaps(&file, me_agent, all, now_ms), &file, me_agent, claims);
339        for o in found {
340            let w = ways(&mut by_agent, &o.agent);
341            match o.kind {
342                overlap::OverlapKind::Dirty => w.dirty.push(rel.clone()),
343                overlap::OverlapKind::Edited => w.edited.push(rel.clone()),
344                overlap::OverlapKind::Claimed => {}
345            }
346            if let Some(c) = o.claim {
347                if !w.claims.contains(&c.what) {
348                    w.claims.push(c.what);
349                }
350            }
351        }
352    }
353    for (agent, their_branch, changed) in branch_files {
354        let shared: Vec<String> = files.iter().filter(|f| changed.contains(f)).cloned().collect();
355        if !shared.is_empty() {
356            ways(&mut by_agent, agent).branch = Some((their_branch.clone(), shared));
357        }
358    }
359    by_agent
360        .into_iter()
361        .filter_map(|w| {
362            let mut doing = Vec::new();
363            if !w.dirty.is_empty() {
364                doing.push(format!("has uncommitted changes in {}", overlap::files_phrase(&w.dirty)));
365            }
366            if !w.edited.is_empty() {
367                doing.push(format!("recently edited {}", overlap::files_phrase(&w.edited)));
368            }
369            for c in &w.claims {
370                doing.push(format!("claimed {c}"));
371            }
372            if let Some((b, shared)) = &w.branch {
373                doing.push(format!("changes {} on its branch {b}", overlap::files_phrase(shared)));
374            }
375            (!doing.is_empty()).then_some(overlap::PrOverlap { agent: w.name, doing })
376        })
377        .collect()
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    const NOW: u64 = 5 * 60 * 60 * 1000;
385
386    fn edit(agent: &str, block: &str, path: &str) -> NewEdit {
387        NewEdit { agent_id: agent.into(), block_id: block.into(), path: path.into() }
388    }
389
390    fn facts(agent: &str, block: &str, root: &str, dirty: &[&str]) -> WorkFacts {
391        WorkFacts {
392            agent: agent.into(),
393            block_id: block.into(),
394            repo: Some("agentmuxai/agentmux".into()),
395            repo_root: Some(root.into()),
396            branch: Some(format!("{}/x", agent.to_lowercase())),
397            dirty_files: dirty.iter().map(|d| d.to_string()).collect(),
398            ..Default::default()
399        }
400    }
401
402    fn settings(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
403        pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
404    }
405
406    #[test]
407    fn the_setting_is_on_by_default_and_off_turns_every_check_off() {
408        assert!(enabled(&settings(&[])));
409        assert!(enabled(&settings(&[(SETTING, serde_json::json!(true))])));
410        let off = settings(&[(SETTING, serde_json::json!(false))]);
411        assert!(!enabled(&off));
412        let mut rl = RateLimiter::default();
413        assert!(admit(vec![edit("A", "b1", "/a/x.rs")], &off, &mut rl, NOW).is_empty());
414        assert_eq!(admit(vec![edit("A", "b1", "/a/x.rs")], &settings(&[]), &mut rl, NOW).len(), 1);
415    }
416
417    #[test]
418    fn an_edit_of_a_file_another_agent_has_uncommitted_gives_the_editor_one_note() {
419        let all = vec![
420            facts("AgentY", "b1", "C:/w/agenty/agentmux", &[]),
421            facts("Agent4", "b2", "C:/w/agent4/agentmux", &["crates/srv/src/muxbus/presence.rs"]),
422        ];
423        let mut rl = RateLimiter::default();
424        let e = edit("AgentY", "b1", r"C:\w\agenty\agentmux\crates\srv\src\muxbus\presence.rs");
425        assert_eq!(
426            note_for(&e, &all, &[], &mut rl, NOW).as_deref(),
427            Some(
428                "[AgentMux] Agent4 also has uncommitted changes in crates/srv/src/muxbus/presence.rs \
429                 (repo agentmuxai/agentmux, branch agent4/x). Message them before changing it."
430            )
431        );
432        assert_eq!(note_for(&e, &all, &[], &mut rl, NOW + 1000), None, "once per pair");
433
434        // The other agent editing its own dirty file hears nothing about itself.
435        let theirs = edit("Agent4", "b2", "C:/w/agent4/agentmux/crates/srv/src/muxbus/presence.rs");
436        assert_eq!(note_for(&theirs, &all, &[], &mut rl, NOW), None);
437        // A file outside every known repository is never matched.
438        assert_eq!(note_for(&edit("AgentY", "b1", "D:/tmp/notes.md"), &all, &[], &mut rl, NOW), None);
439    }
440
441    /// The watcher's hand-off returns while the check is still running: a
442    /// check that never finishes can't hold the sweep up.
443    #[tokio::test]
444    async fn the_check_runs_on_its_own_task() {
445        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<usize>();
446        let (_release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
447        let t0 = std::time::Instant::now();
448        dispatch(vec![edit("A", "b1", "/x")], move |edits| async move {
449            let _ = started_tx.send(edits.len());
450            let _ = release_rx.await; // never released while the caller waits
451        });
452        assert!(t0.elapsed() < std::time::Duration::from_millis(500), "dispatch waited on the check");
453        assert_eq!(started_rx.await.unwrap(), 1, "the check still ran");
454    }
455
456    #[test]
457    fn nothing_is_dispatched_without_edits_or_a_runtime() {
458        let mut ran = false;
459        dispatch(Vec::new(), |_| {
460            ran = true;
461            async {}
462        });
463        assert!(!ran);
464        // Outside a runtime the check is dropped rather than panicking.
465        dispatch(vec![edit("A", "b1", "/x")], |_| async {});
466    }
467
468    #[test]
469    fn a_pr_names_each_agent_working_on_its_files_and_how() {
470        let mut agent4 = facts("Agent4", "b4", "/w4/agentmux", &["crates/a.rs"]);
471        agent4.recent_files = vec![super::super::FactFile {
472            path: "/w4/agentmux/crates/b.rs".into(),
473            rel: Some("crates/b.rs".into()),
474            tool: "Edit".into(),
475            ts_ms: NOW - 60_000,
476        }];
477        let all = vec![facts("AgentY", "by", "/wy/agentmux", &[]), agent4, facts("Agent5", "b5", "/w5/agentmux", &[])];
478        let files: Vec<String> = ["crates/a.rs", "crates/b.rs", "crates/c.rs"].iter().map(|s| s.to_string()).collect();
479        let branch_files = vec![("Agent5".to_string(), "agent5/x".to_string(), vec!["crates/c.rs".to_string(), "other.rs".to_string()])];
480        let got = pr_overlaps("AgentY", "agentmuxai/agentmux", "agentmuxai/agentmux", &files, &all, &[], &branch_files, NOW);
481        assert_eq!(got, vec![
482            overlap::PrOverlap {
483                agent: "Agent4".into(),
484                doing: vec!["has uncommitted changes in crates/a.rs".into(), "recently edited crates/b.rs".into()],
485            },
486            overlap::PrOverlap { agent: "Agent5".into(), doing: vec!["changes crates/c.rs on its branch agent5/x".into()] },
487        ]);
488        let text = overlap::pr_note_text("agenty/x", &got);
489        assert!(text.starts_with("[AgentMux] The PR you are opening from agenty/x changes files other agents are working on: Agent4 has uncommitted changes in crates/a.rs and recently edited crates/b.rs; Agent5 changes crates/c.rs on its branch agent5/x."), "{text}");
490        assert!(!text.contains('\n'));
491    }
492
493    #[test]
494    fn a_pr_nobody_else_touches_gets_no_note() {
495        let all = vec![facts("AgentY", "by", "/wy/agentmux", &[]), facts("Agent4", "b4", "/w4/agentmux", &["elsewhere.rs"])];
496        let files = vec!["crates/a.rs".to_string()];
497        assert!(pr_overlaps("AgentY", "agentmuxai/agentmux", "agentmuxai/agentmux", &files, &all, &[], &[], NOW).is_empty());
498    }
499
500    #[test]
501    fn one_pr_note_per_pane_and_branch_every_two_hours() {
502        let mut sent = HashMap::new();
503        assert!(admit_pr_note(&mut sent, "b", "agenty/x", NOW));
504        assert!(!admit_pr_note(&mut sent, "b", "agenty/x", NOW + 60_000));
505        assert!(admit_pr_note(&mut sent, "b", "agenty/y", NOW + 60_000), "another branch");
506        assert!(admit_pr_note(&mut sent, "b", "agenty/x", NOW + PR_NOTE_EVERY_MS + 1));
507    }
508}