1use 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
37pub const SETTING: &str = "agent:overlapnotes";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct NewEdit {
43 pub agent_id: String,
44 pub block_id: String,
45 pub path: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct NewPr {
53 pub agent_id: String,
54 pub block_id: String,
55 pub head: Option<String>,
58 pub base: Option<String>,
61}
62
63const PR_NOTE_EVERY_MS: u64 = 2 * 60 * 60 * 1000;
65const MAX_BRANCH_CLONES: usize = 8;
67
68struct Context {
70 store: Arc<Store>,
71 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
85pub 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
97pub fn enabled(extra: &HashMap<String, serde_json::Value>) -> bool {
99 extra.get(SETTING).and_then(|v| v.as_bool()).unwrap_or(true)
100}
101
102fn 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
116fn 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
134pub 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
147async 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, ®s_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 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
180fn 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
201pub 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
226fn 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, ®s_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 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 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#[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 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 assert_eq!(note_for(&edit("AgentY", "b1", "D:/tmp/notes.md"), &all, &[], &mut rl, NOW), None);
439 }
440
441 #[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; });
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 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}