1use std::collections::{BTreeSet, HashMap};
28use std::fmt;
29use std::future::Future;
30use std::path::{Path, PathBuf};
31use std::pin::Pin;
32use std::process::{Command, Stdio};
33use std::time::{Duration, Instant};
34
35use anyhow::{Context as _, Result, bail};
36use serde::Deserialize;
37
38use crate::agent;
39use crate::config::Config;
40use crate::prompt;
41
42use crate::land::PrLifecycle;
43use crate::proc::Quiet as _;
44use crate::queue::{Queue, Task, TaskStatus};
45use crate::run::RunStatus;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Signal {
50 Branch,
52 Sha,
54 Pr,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Owner {
61 Task,
63 Run,
65 Pr,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Hit {
72 pub owner: Owner,
74 pub id: String,
76 pub status: String,
78 pub signal: Signal,
80 pub token: String,
82 pub via: String,
84 pub about: String,
87}
88
89impl fmt::Display for Hit {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 let kind = match self.owner {
92 Owner::Task => "task",
93 Owner::Run => "run",
94 Owner::Pr => "pull request",
95 };
96 let what = match self.signal {
97 Signal::Branch => "names branch",
98 Signal::Sha => "names commit",
99 Signal::Pr => "names pull request",
100 };
101 write!(
102 f,
103 "{kind} {} ({}): this work {what} {}, {}",
104 crate::queue::short(&self.id),
105 self.status,
106 self.token,
107 self.via
108 )
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct Duplicate {
115 pub hits: Vec<Hit>,
117 pub judge: Option<String>,
120}
121
122impl Duplicate {
123 pub fn new(hits: Vec<Hit>) -> Self {
125 Self { hits, judge: None }
126 }
127
128 pub fn render(&self, override_hint: &str) -> String {
130 let mut out = String::from("this looks like work that is already in flight:");
131 for h in &self.hits {
132 out.push_str("\n - ");
133 out.push_str(&h.to_string());
134 }
135 if let Some(j) = &self.judge {
136 out.push('\n');
137 out.push_str(j);
138 }
139 out.push('\n');
140 out.push_str(override_hint);
141 out
142 }
143}
144
145impl fmt::Display for Duplicate {
146 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147 f.write_str(&self.render(
148 "If it is not a duplicate, pass --force to file it anyway \
149 (an agent should report this to the operator instead).",
150 ))
151 }
152}
153
154impl std::error::Error for Duplicate {}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Judgement {
159 pub duplicate: bool,
161 pub reason: String,
163 pub agent: String,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Screened {
170 Clean,
172 Cleared(Judgement),
174 Unjudged(String),
176}
177
178pub type JudgeFuture = Pin<Box<dyn Future<Output = Result<Judgement>> + Send>>;
181
182const JUDGE_BUDGET: Duration = Duration::from_secs(45);
184const JUDGE_TURN: Duration = Duration::from_secs(30);
186
187fn parse_judgement(text: &str) -> Result<(bool, String)> {
191 #[derive(Deserialize)]
192 struct Raw {
193 duplicate: bool,
194 reason: String,
195 }
196 let mut body = text.trim();
200 if let Some(rest) = body.strip_prefix("```") {
201 let rest = rest.strip_prefix("json").unwrap_or(rest);
202 body = rest.trim().strip_suffix("```").unwrap_or(rest).trim();
203 }
204 let raw: Raw =
205 serde_json::from_str(body).context("the judge's reply is not a single JSON object")?;
206 let reason = raw.reason.split_whitespace().collect::<Vec<_>>().join(" ");
207 if reason.is_empty() {
208 bail!("the judge gave no reason");
209 }
210 Ok((raw.duplicate, reason.chars().take(300).collect()))
211}
212
213pub async fn chain_judge(
218 cfg: &Config,
219 repo: &Path,
220 instruction: String,
221 hits: Vec<Hit>,
222) -> Result<Judgement> {
223 let chain = agent::pick_chain(
224 &cfg.agents,
225 cfg.roles
226 .dupes_judge
227 .as_ref()
228 .or(cfg.roles.chatter.as_ref()),
229 &agent::installed,
230 "dupes_judge",
231 )?;
232 let claims: Vec<(String, String)> = hits
233 .iter()
234 .map(|h| (h.to_string(), h.about.clone()))
235 .collect();
236 let body = prompt::dupes_judge(&instruction, &claims);
237 let artifacts = std::env::temp_dir().join(format!("magi-dupes-{:016x}", crate::rng::entropy()));
238 let started = Instant::now();
239 let mut last: anyhow::Error = anyhow::anyhow!("no judge agent ran");
240 let mut result = None;
241 for spec in &chain {
242 let left = JUDGE_BUDGET.saturating_sub(started.elapsed());
243 if left.is_zero() {
244 break;
245 }
246 let mut seat = agent::SeatState::new("dupes", &spec.id, crate::rng::entropy());
247 let inv = agent::Invocation {
248 scope: crate::agent::InvocationScope::Repository,
249 coordination: cfg.coordination.as_ref(),
250 fetched_base: None,
251 cwd: repo,
252 prompt: &body,
253 timeout: left.min(JUDGE_TURN),
254 allow_write: false,
255 unsandboxed: false,
256 sessions: false,
257 artifacts: &artifacts,
258 stem: &format!("judge-{}", spec.id),
259 run: "dupes",
260 node: "dupes",
261 cache_dir: None,
262 attachments: &[],
263 writable: &[],
264 };
265 let out = agent::invoke(spec, &mut seat, &inv).await;
266 if agent::chain_advances(&out) {
267 last = match out {
268 Err(e) => e.context(format!("judge `{}` failed", spec.id)),
269 Ok(o) => anyhow::anyhow!(
270 "judge `{}` gave no usable reply ({})",
271 spec.id,
272 crate::notices::turn_status(o.exit_code, o.timed_out, o.quota_exhausted())
273 ),
274 };
275 continue;
276 }
277 result = Some(
278 out.and_then(|o| parse_judgement(&o.text))
279 .map(|(duplicate, reason)| Judgement {
280 duplicate,
281 reason,
282 agent: spec.id.clone(),
283 }),
284 );
285 break;
286 }
287 let _ = std::fs::remove_dir_all(&artifacts);
288 result.unwrap_or(Err(last))
289}
290
291pub async fn screen(
306 hits: Vec<Hit>,
307 text: &str,
308 review_branch: Option<&str>,
309 judge: &(dyn Fn(String, Vec<Hit>) -> JudgeFuture + Sync),
310) -> Result<Screened, Duplicate> {
311 if hits.is_empty() {
312 return Ok(Screened::Clean);
313 }
314 let unjudged = |why: String| {
315 tracing::warn!(hits = hits.len(), %why, "duplicate check: judgement unavailable, letting the work through");
316 Ok(Screened::Unjudged(why))
317 };
318 let mut subject = squeeze(text, prompt::DUPES_JUDGE_MAX_CHARS);
319 if let Some(b) = review_branch {
320 if !subject.is_empty() {
321 subject.push_str("\n\n");
322 }
323 subject.push_str(&format!(
324 "(This is a review-only request for branch `{b}`: it would do work on that branch.)"
325 ));
326 }
327 match judge(subject, hits.clone()).await {
328 Ok(j) if !j.duplicate => {
329 tracing::info!(
330 agent = %j.agent,
331 reason = %j.reason,
332 hits = hits.len(),
333 "duplicate check: the judge says this is not duplicate work"
334 );
335 Ok(Screened::Cleared(j))
336 }
337 Ok(j) => Err(Duplicate {
338 hits,
339 judge: Some(format!("judge ({}): duplicate - {}", j.agent, j.reason)),
340 }),
341 Err(e) => unjudged(format!("{e:#}")),
342 }
343}
344
345pub async fn screen_with_config(
348 hits: Vec<Hit>,
349 text: &str,
350 review_branch: Option<&str>,
351 repo: &Path,
352 cfg: Option<&Config>,
353) -> Result<Screened, Duplicate> {
354 let judge = |instruction: String, hits: Vec<Hit>| -> JudgeFuture {
355 let cfg = cfg.cloned();
356 let repo = repo.to_path_buf();
357 Box::pin(async move {
358 match cfg {
359 Some(cfg) => {
360 let dir = repo.clone();
361 let hits = tokio::task::spawn_blocking(move || {
362 let mut hits = hits;
363 describe_forge_hits(&dir, &mut hits);
364 hits
365 })
366 .await
367 .context("describing the pull request")?;
368 chain_judge(&cfg, &repo, instruction, hits).await
369 }
370 None => bail!("no readable configuration to resolve a judge agent from"),
371 }
372 })
373 };
374 screen(hits, text, review_branch, &judge).await
375}
376
377#[derive(Debug, Default, Deserialize)]
380struct RunView {
381 #[serde(default)]
382 id: String,
383 #[serde(default)]
384 repo: PathBuf,
385 #[serde(default)]
386 status: String,
387 #[serde(default)]
388 base_commit: String,
389 #[serde(default)]
390 instruction: String,
391 #[serde(default)]
392 candidates: Vec<CandView>,
393 #[serde(default)]
394 pr: Option<PrView>,
395 #[serde(default)]
397 released_to: Option<String>,
398 #[serde(default)]
399 pr_head: Option<String>,
400 #[serde(default)]
401 pr_taken_over_by: Option<String>,
402}
403
404#[derive(Debug, Default, Deserialize)]
405struct CandView {
406 #[serde(default)]
407 branch: String,
408}
409
410#[derive(Debug, Default, Deserialize)]
411struct PrView {
412 #[serde(default)]
413 url: String,
414 #[serde(default)]
415 number: u64,
416 #[serde(default)]
417 state: String,
418}
419
420impl RunView {
421 fn read(runs_root: &Path, id: &str) -> Option<Self> {
422 let raw = std::fs::read_to_string(runs_root.join(id).join("run.json")).ok()?;
423 serde_json::from_str(&raw).ok()
424 }
425
426 fn terminal(&self) -> bool {
429 serde_json::from_value::<RunStatus>(serde_json::Value::String(self.status.clone()))
430 .map(RunStatus::done)
431 .unwrap_or(false)
432 }
433
434 fn pr_open(&self) -> bool {
435 self.pr.as_ref().is_some_and(|p| p.state == "open")
436 }
437}
438
439struct Staleness<'a> {
456 runs_root: &'a Path,
457 lookup: &'a dyn Fn(&Path, u64) -> Option<PrLifecycle>,
458 cache: HashMap<u64, bool>,
459 asked: usize,
460 failed: bool,
461}
462
463const MAX_FORGE_LOOKUPS: usize = 5;
466
467impl<'a> Staleness<'a> {
468 fn new(runs_root: &'a Path, lookup: &'a dyn Fn(&Path, u64) -> Option<PrLifecycle>) -> Self {
469 Self {
470 runs_root,
471 lookup,
472 cache: HashMap::new(),
473 asked: 0,
474 failed: false,
475 }
476 }
477
478 fn pr_released(&mut self, repo: &Path, view: &RunView) -> bool {
481 if let Some(next) = &view.pr_taken_over_by
482 && next != &view.id
483 && RunView::read(self.runs_root, next).is_some()
484 {
485 return true;
486 }
487 let Some(pr) = view.pr.as_ref().filter(|p| p.state == "open") else {
488 return false;
489 };
490 if let Some(next) = &view.released_to
491 && next != &view.id
492 && RunView::read(self.runs_root, next).is_some()
493 {
494 return true;
495 }
496 if pr.number == 0 {
497 return false;
498 }
499 if let Some(known) = self.cache.get(&pr.number) {
500 return *known;
501 }
502 if self.failed || self.asked >= MAX_FORGE_LOOKUPS {
503 return false;
504 }
505 self.asked += 1;
506 let settled = match (self.lookup)(repo, pr.number) {
507 Some(PrLifecycle::Merged | PrLifecycle::Closed) => true,
508 Some(PrLifecycle::Open) => false,
509 None => {
510 self.failed = true;
511 false
512 }
513 };
514 self.cache.insert(pr.number, settled);
515 settled
516 }
517}
518
519#[derive(Debug, Clone)]
521struct Claim {
522 owner: Owner,
523 id: String,
524 status: String,
525 via: String,
526 about: String,
528 branch: Option<String>,
529 base: Option<String>,
531 pr: Option<(u64, String)>,
533}
534
535pub fn check(
539 queue: &Queue,
540 runs_root: &Path,
541 repo: &Path,
542 text: &str,
543 review_branch: Option<&str>,
544 ignore_task: Option<&str>,
545) -> Vec<Hit> {
546 check_with(
547 queue,
548 runs_root,
549 repo,
550 text,
551 review_branch,
552 ignore_task,
553 &gh_open_pr,
554 &gh_pr_state,
555 )
556}
557
558#[allow(clippy::too_many_arguments)]
565pub fn check_with(
566 queue: &Queue,
567 runs_root: &Path,
568 repo: &Path,
569 text: &str,
570 review_branch: Option<&str>,
571 ignore_task: Option<&str>,
572 open_pr: &dyn Fn(&Path, u64) -> Option<String>,
573 pr_state: &dyn Fn(&Path, u64) -> Option<PrLifecycle>,
574) -> Vec<Hit> {
575 let mut stale = Staleness::new(runs_root, pr_state);
576 let mut idents = Idents::default();
577 let here = idents.of(repo);
578 let tasks = queue.list();
579 let own_runs: BTreeSet<String> = tasks
580 .iter()
581 .filter(|t| Some(t.id.as_str()) == ignore_task)
582 .flat_map(|t| t.runs.iter().cloned())
583 .collect();
584 let own_prs: BTreeSet<u64> = own_runs
586 .iter()
587 .filter_map(|id| RunView::read(runs_root, id))
588 .filter_map(|v| v.pr.map(|p| p.number))
589 .collect();
590
591 let mut claims: Vec<Claim> = Vec::new();
592 let mut from_task: BTreeSet<String> = BTreeSet::new();
593 for t in tasks
594 .iter()
595 .filter(|t| t.status != TaskStatus::Done && Some(t.id.as_str()) != ignore_task)
596 .filter(|t| idents.of(&t.repo) == here)
597 {
598 claims.extend(task_claims(t, runs_root, &mut from_task, repo, &mut stale));
599 }
600 for id in crate::run::list_ids_in(runs_root) {
601 if own_runs.contains(&id) {
602 continue;
603 }
604 let Some(view) = RunView::read(runs_root, &id) else {
605 continue;
606 };
607 if from_task.contains(&id) && view.pr_head.is_some() {
610 continue;
611 }
612 if (view.terminal() && !view.pr_open()) || idents.of(&view.repo) != here {
613 continue;
614 }
615 let released = stale.pr_released(repo, &view);
616 if view.terminal() && released {
619 continue;
620 }
621 claims.extend(run_claims(
622 &view,
623 Owner::Run,
624 None,
625 "its own run",
626 !released,
627 ("", &view.instruction),
628 ));
629 }
630
631 let mut hits: Vec<Hit> = Vec::new();
632 let mut push = |c: &Claim, signal: Signal, token: String| {
633 let hit = Hit {
634 owner: c.owner.clone(),
635 id: c.id.clone(),
636 status: c.status.clone(),
637 signal,
638 token,
639 via: c.via.clone(),
640 about: c.about.clone(),
641 };
642 if !hits.contains(&hit) {
643 hits.push(hit);
644 }
645 };
646
647 let prs = pr_numbers(text);
648 let shas = sha_candidates(repo, text);
649 for c in &claims {
650 if let Some(b) = &c.branch {
651 if names_branch(text, b) || review_branch == Some(b.as_str()) {
652 push(c, Signal::Branch, b.clone());
653 }
654 if let Some(base) = &c.base {
655 for sha in &shas {
656 if on_branch_only(repo, sha, b, base) {
657 push(c, Signal::Sha, short_sha(sha));
658 }
659 }
660 }
661 }
662 if let Some((n, url)) = &c.pr {
663 if prs.contains(&Mention::Number(*n))
664 || prs.iter().any(|p| matches!(p, Mention::Url(u) if u == url))
665 {
666 push(c, Signal::Pr, format!("#{n}"));
667 }
668 }
669 }
670 let mut asked = BTreeSet::new();
671 for n in prs.iter().filter_map(|m| match m {
672 Mention::Number(n) => Some(*n),
673 Mention::Url(u) => u.rsplit('/').next().and_then(|d| d.parse().ok()),
674 }) {
675 let token = format!("#{n}");
676 if hits
677 .iter()
678 .any(|h| h.signal == Signal::Pr && h.token == token)
679 || own_prs.contains(&n)
680 || !asked.insert(n)
681 {
682 continue;
683 }
684 if let Some(url) = open_pr(repo, n) {
685 hits.push(Hit {
686 owner: Owner::Pr,
687 id: token.clone(),
688 status: "open".into(),
689 signal: Signal::Pr,
690 token,
691 via: format!("an open pull request with no run record here ({url})"),
692 about: String::new(),
693 });
694 }
695 }
696 hits
697}
698
699fn gh_open_pr(repo: &Path, n: u64) -> Option<String> {
704 let v = gh_pr_view(repo, n, "state,url")?;
705 (v["state"] == "OPEN")
706 .then(|| v["url"].as_str().map(str::to_owned))
707 .flatten()
708}
709
710fn gh_pr_state(repo: &Path, n: u64) -> Option<PrLifecycle> {
713 match gh_pr_view(repo, n, "state,url")?["state"].as_str()? {
714 "OPEN" => Some(PrLifecycle::Open),
715 "MERGED" => Some(PrLifecycle::Merged),
716 "CLOSED" => Some(PrLifecycle::Closed),
717 _ => None,
718 }
719}
720
721fn gh_pr_view(repo: &Path, n: u64, fields: &str) -> Option<serde_json::Value> {
722 let mut child = Command::new("gh")
723 .quiet()
724 .args(["pr", "view", &n.to_string(), "--json", fields])
725 .current_dir(repo)
726 .env_remove("GH_REPO")
727 .env("GH_PROMPT_DISABLED", "1")
728 .stdin(Stdio::null())
729 .stdout(Stdio::piped())
730 .stderr(Stdio::null())
731 .spawn()
732 .ok()?;
733 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
734 loop {
735 match child.try_wait().ok()? {
736 Some(status) if status.success() => break,
737 Some(_) => return None,
738 None if std::time::Instant::now() >= deadline => {
739 let _ = child.kill();
740 let _ = child.wait();
741 return None;
742 }
743 None => std::thread::sleep(std::time::Duration::from_millis(50)),
744 }
745 }
746 let mut raw = String::new();
747 std::io::Read::read_to_string(&mut child.stdout.take()?, &mut raw).ok()?;
748 serde_json::from_str(&raw).ok()
749}
750
751fn describe_forge_hits(repo: &Path, hits: &mut [Hit]) {
756 for h in hits
757 .iter_mut()
758 .filter(|h| h.owner == Owner::Pr && h.about.is_empty())
759 {
760 let Some(n) = h.token.trim_start_matches('#').parse::<u64>().ok() else {
761 continue;
762 };
763 if let Some(v) = gh_pr_view(repo, n, "title,body") {
764 h.about = about_of(
765 v["title"].as_str().unwrap_or(""),
766 v["body"].as_str().unwrap_or(""),
767 );
768 }
769 }
770}
771
772fn squeeze(s: &str, max: usize) -> String {
776 let n = s.chars().count();
777 if n <= max {
778 return s.to_owned();
779 }
780 let half = max / 2;
781 let head: String = s.chars().take(half).collect();
782 let tail: String = s.chars().skip(n - half).collect();
783 format!(
784 "{head}\n[... {} characters omitted ...]\n{tail}",
785 n - 2 * half
786 )
787}
788
789fn about_of(title: &str, instruction: &str) -> String {
794 let body = instruction.split_whitespace().collect::<Vec<_>>().join(" ");
795 let title = title.trim();
796 let full = match (title.is_empty(), body.is_empty()) {
797 (true, _) => body,
798 (false, true) => title.to_owned(),
799 (false, false) => format!("{title}: {body}"),
800 };
801 squeeze(&full, ABOUT_MAX_CHARS)
802}
803
804const ABOUT_MAX_CHARS: usize = prompt::DUPES_JUDGE_MAX_CHARS;
808
809fn task_claims(
810 t: &Task,
811 runs_root: &Path,
812 seen: &mut BTreeSet<String>,
813 repo: &Path,
814 stale: &mut Staleness<'_>,
815) -> Vec<Claim> {
816 let status = t.status.as_str().to_owned();
817 let mut out = Vec::new();
818 if let Some(b) = &t.review_branch {
819 out.push(Claim {
820 owner: Owner::Task,
821 id: t.id.clone(),
822 status: status.clone(),
823 via: "its review branch".into(),
824 about: about_of(&t.title, &t.instruction),
825 branch: Some(b.clone()),
826 base: None,
827 pr: None,
828 });
829 }
830 for rid in &t.runs {
831 if let Some(view) = RunView::read(runs_root, rid) {
832 seen.insert(rid.clone());
833 let live_pr = !(view.terminal() && stale.pr_released(repo, &view));
836 if view.terminal() && !live_pr && view.pr_taken_over_by.is_some() {
837 continue;
838 }
839 out.extend(run_claims(
840 &view,
841 Owner::Task,
842 Some((&t.id, &status)),
843 &format!("produced by its run {}", crate::queue::short(rid)),
844 live_pr,
845 (&t.title, &t.instruction),
846 ));
847 }
848 }
849 out
850}
851
852fn run_claims(
857 view: &RunView,
858 owner: Owner,
859 task: Option<(&str, &str)>,
860 via: &str,
861 live_pr: bool,
862 (title, instruction): (&str, &str),
863) -> Vec<Claim> {
864 let about = about_of(title, instruction);
865 let (id, status) = match task {
866 Some((id, status)) => (id.to_owned(), status.to_owned()),
867 None => (view.id.clone(), view.status.clone()),
868 };
869 let pr = view
870 .pr
871 .as_ref()
872 .filter(|p| live_pr && p.state == "open" && p.number > 0)
873 .map(|p| (p.number, p.url.clone()));
874 let via_pr = |extra: &str| match &pr {
875 Some((n, _)) => format!("{via} (PR #{n} open){extra}"),
876 None => format!("{via}{extra}"),
877 };
878 let mut out: Vec<Claim> = view
879 .candidates
880 .iter()
881 .filter(|c| !c.branch.is_empty())
882 .map(|c| Claim {
883 owner: owner.clone(),
884 id: id.clone(),
885 status: status.clone(),
886 via: via_pr(""),
887 about: about.clone(),
888 branch: Some(c.branch.clone()),
889 base: (!view.base_commit.is_empty()).then(|| view.base_commit.clone()),
890 pr: None,
891 })
892 .collect();
893 let settled = view
896 .pr
897 .as_ref()
898 .is_some_and(|p| p.state == "merged" || p.state == "closed");
899 if live_pr
900 && !settled
901 && let Some(branch) = &view.pr_head
902 {
903 out.push(Claim {
904 owner: owner.clone(),
905 id: id.clone(),
906 status: status.clone(),
907 via: via_pr(""),
908 about: about.clone(),
909 branch: Some(branch.clone()),
910 base: None,
911 pr: None,
912 });
913 }
914 if pr.is_some() {
915 out.push(Claim {
916 owner,
917 id,
918 status,
919 via: via_pr(""),
920 about,
921 branch: None,
922 base: None,
923 pr,
924 });
925 }
926 out
927}
928
929#[derive(Default)]
932struct Idents(HashMap<PathBuf, PathBuf>);
933
934impl Idents {
935 fn of(&mut self, path: &Path) -> PathBuf {
936 self.0
937 .entry(path.to_path_buf())
938 .or_insert_with(|| {
939 git(
940 path,
941 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
942 )
943 .map(PathBuf::from)
944 .and_then(|p| p.canonicalize().ok())
945 .or_else(|| path.canonicalize().ok())
946 .unwrap_or_else(|| path.to_path_buf())
947 })
948 .clone()
949 }
950}
951
952fn git(cwd: &Path, args: &[&str]) -> Option<String> {
953 let out = Command::new("git")
954 .quiet()
955 .args(args)
956 .current_dir(cwd)
957 .stdin(Stdio::null())
958 .stderr(Stdio::null())
959 .env("GIT_TERMINAL_PROMPT", "0")
960 .output()
961 .ok()?;
962 out.status
963 .success()
964 .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
965}
966
967fn git_ok(cwd: &Path, args: &[&str]) -> bool {
968 git(cwd, args).is_some()
969}
970
971fn is_ref_char(c: char) -> bool {
972 c.is_alphanumeric() || matches!(c, '_' | '-')
973}
974
975fn names_branch(text: &str, branch: &str) -> bool {
979 if branch.len() < 3 {
980 return false;
981 }
982 text.match_indices(branch).any(|(i, _)| {
983 let before = text[..i].chars().next_back();
984 let after = text[i + branch.len()..].chars().next();
985 let after_ok = match after {
986 None => true,
987 Some('/') => false,
988 Some('.') => !text[i + branch.len() + 1..]
989 .chars()
990 .next()
991 .is_some_and(is_ref_char),
992 Some(c) => !is_ref_char(c),
993 };
994 before.is_none_or(|c| !is_ref_char(c) && c != '.') && after_ok
995 })
996}
997
998#[derive(Debug, PartialEq, Eq)]
999enum Mention {
1000 Number(u64),
1001 Url(String),
1002}
1003
1004fn pr_numbers(text: &str) -> Vec<Mention> {
1006 let mut out = Vec::new();
1007 let bytes = text.as_bytes();
1008 let digits = |from: usize| -> Option<(u64, usize)> {
1009 let n = text[from..].bytes().take_while(u8::is_ascii_digit).count();
1010 (n > 0 && n < 10)
1011 .then(|| text[from..from + n].parse().ok().map(|v| (v, from + n)))
1012 .flatten()
1013 };
1014 for (i, _) in text.match_indices('#') {
1015 if let Some((n, _)) = digits(i + 1) {
1016 let word_before = i > 0 && is_ref_char(bytes[i - 1] as char);
1017 if !word_before {
1018 out.push(Mention::Number(n));
1019 }
1020 }
1021 }
1022 let lower = text.to_ascii_lowercase();
1023 for key in ["pull request ", "pr "] {
1024 for (i, _) in lower.match_indices(key) {
1025 if i > 0 && is_ref_char(bytes[i - 1] as char) {
1026 continue;
1027 }
1028 let from = i + key.len();
1029 let from = if text[from..].starts_with('#') {
1030 from + 1
1031 } else {
1032 from
1033 };
1034 if let Some((n, _)) = digits(from) {
1035 out.push(Mention::Number(n));
1036 }
1037 }
1038 }
1039 for (i, _) in text.match_indices("/pull/") {
1040 if let Some((_, end)) = digits(i + 6) {
1041 let start = text[..i]
1042 .rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '<' | '"' | '\''))
1043 .map_or(0, |p| p + 1);
1044 out.push(Mention::Url(text[start..end].to_owned()));
1045 }
1046 }
1047 out
1048}
1049
1050fn sha_candidates(repo: &Path, text: &str) -> Vec<String> {
1052 let mut seen = BTreeSet::new();
1053 let mut out = Vec::new();
1054 for word in text.split(|c: char| !c.is_ascii_alphanumeric()) {
1055 if !(7..=40).contains(&word.len()) || !word.bytes().all(|b| b.is_ascii_hexdigit()) {
1056 continue;
1057 }
1058 if seen.len() >= 16 || !seen.insert(word.to_ascii_lowercase()) {
1059 continue;
1060 }
1061 if let Some(full) = git(
1062 repo,
1063 &[
1064 "rev-parse",
1065 "--verify",
1066 "--quiet",
1067 &format!("{word}^{{commit}}"),
1068 ],
1069 ) {
1070 out.push(full);
1071 }
1072 }
1073 out
1074}
1075
1076fn on_branch_only(repo: &Path, sha: &str, branch: &str, base: &str) -> bool {
1079 let tip = format!("{branch}^{{commit}}");
1080 git_ok(repo, &["rev-parse", "--verify", "--quiet", &tip])
1081 && git_ok(repo, &["merge-base", "--is-ancestor", sha, branch])
1082 && !git_ok(repo, &["merge-base", "--is-ancestor", sha, base])
1083}
1084
1085fn short_sha(sha: &str) -> String {
1086 sha.chars().take(7).collect()
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092 use crate::queue::{Source, Task};
1093
1094 #[cfg(unix)]
1095 fn judge_agent(id: &str, fails: bool) -> crate::config::AgentSpec {
1096 crate::config::AgentSpec {
1097 id: id.to_owned(),
1098 kind: crate::config::AgentKind::Command,
1099 model: None,
1100 command: vec![
1101 "sh".into(),
1102 "-c".into(),
1103 format!(
1104 "echo {id} >> calls; {}",
1105 if fails {
1106 "exit 1"
1107 } else {
1108 r#"printf '%s' '{"duplicate":false,"reason":"different work"}'"#
1109 }
1110 ),
1111 ],
1112 extra_args: Vec::new(),
1113 env: Default::default(),
1114 prompt_delivery: None,
1115 max_turn_secs: None,
1116 }
1117 }
1118
1119 #[cfg(unix)]
1120 fn judge_config() -> Config {
1121 let mut cfg = Config {
1122 agents: vec![
1123 judge_agent("chat", false),
1124 judge_agent("dedicated", false),
1125 judge_agent("failed", true),
1126 ],
1127 ..Config::default()
1128 };
1129 cfg.roles.chatter = Some("chat".into());
1130 cfg
1131 }
1132
1133 #[cfg(unix)]
1134 #[tokio::test]
1135 async fn dupes_judge_overrides_chatter() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let mut cfg = judge_config();
1138 cfg.roles.dupes_judge = Some("dedicated".into());
1139 let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1140 .await
1141 .unwrap();
1142 assert_eq!(verdict.agent, "dedicated");
1143 assert!(!verdict.duplicate);
1144 assert_eq!(
1145 std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1146 "dedicated\n"
1147 );
1148 assert_eq!(cfg.roles.chatter.as_ref().unwrap().ids(), ["chat"]);
1149 }
1150
1151 #[cfg(unix)]
1152 #[tokio::test]
1153 async fn dupes_judge_unset_inherits_chatter() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let cfg = judge_config();
1156 let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1157 .await
1158 .unwrap();
1159 assert_eq!(verdict.agent, "chat");
1160 assert_eq!(
1161 std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1162 "chat\n"
1163 );
1164 }
1165
1166 #[cfg(unix)]
1167 #[tokio::test]
1168 async fn dupes_judge_dedicated_and_inherited_chains_advance_on_failure() {
1169 for dedicated in [true, false] {
1170 let dir = tempfile::tempdir().unwrap();
1171 let mut cfg = judge_config();
1172 let chain =
1173 crate::config::AgentChoice::Chain(vec!["failed".into(), "dedicated".into()]);
1174 if dedicated {
1175 cfg.roles.dupes_judge = Some(chain);
1176 } else {
1177 cfg.roles.chatter = Some(chain);
1178 }
1179 let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1180 .await
1181 .unwrap();
1182 assert_eq!(verdict.agent, "dedicated");
1183 assert_eq!(
1184 std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1185 "failed\ndedicated\n"
1186 );
1187 }
1188 }
1189
1190 #[cfg(unix)]
1191 #[tokio::test]
1192 async fn dupes_judge_unavailable_choice_does_not_inherit_chatter() {
1193 let dir = tempfile::tempdir().unwrap();
1194 let mut cfg = judge_config();
1195 cfg.roles.dupes_judge = Some("missing".into());
1196 let err = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1197 .await
1198 .unwrap_err();
1199 assert!(err.to_string().contains("[roles] dupes_judge"), "{err}");
1200 assert!(!dir.path().join("calls").exists());
1201 }
1202
1203 #[cfg(unix)]
1204 #[tokio::test]
1205 async fn dupes_judge_empty_choice_uses_default_instead_of_chatter() {
1206 let dir = tempfile::tempdir().unwrap();
1207 let mut cfg = judge_config();
1208 cfg.roles.chatter = Some("dedicated".into());
1209 cfg.roles.dupes_judge = Some(crate::config::AgentChoice::Chain(vec![]));
1210 let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1211 .await
1212 .unwrap();
1213 assert_eq!(
1214 verdict.agent, "chat",
1215 "the first roster agent is the default"
1216 );
1217 assert_eq!(
1218 std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1219 "chat\n"
1220 );
1221 }
1222
1223 struct Fx {
1224 _tmp: tempfile::TempDir,
1225 repo: PathBuf,
1226 runs: PathBuf,
1227 q: Queue,
1228 }
1229
1230 fn sh(cwd: &Path, args: &[&str]) -> String {
1231 let out = Command::new("git")
1232 .quiet()
1233 .args(["-c", "user.name=t", "-c", "user.email=t@t"])
1234 .args(args)
1235 .current_dir(cwd)
1236 .output()
1237 .unwrap();
1238 assert!(out.status.success(), "git {args:?}: {out:?}");
1239 String::from_utf8_lossy(&out.stdout).trim().to_owned()
1240 }
1241
1242 fn fx() -> (Fx, String, String) {
1245 let tmp = tempfile::tempdir().unwrap();
1246 let repo = tmp.path().join("repo");
1247 std::fs::create_dir_all(&repo).unwrap();
1248 sh(&repo, &["init", "-q", "-b", "main"]);
1249 std::fs::write(repo.join("a"), "1").unwrap();
1250 sh(&repo, &["add", "."]);
1251 sh(&repo, &["commit", "-q", "-m", "base"]);
1252 let base = sh(&repo, &["rev-parse", "HEAD"]);
1253 sh(&repo, &["checkout", "-q", "-b", "magi/aaaa/A"]);
1254 std::fs::write(repo.join("a"), "2").unwrap();
1255 sh(&repo, &["commit", "-q", "-am", "work"]);
1256 let tip = sh(&repo, &["rev-parse", "HEAD"]);
1257 sh(&repo, &["checkout", "-q", "main"]);
1258 let runs = tmp.path().join("runs");
1259 std::fs::create_dir_all(&runs).unwrap();
1260 let q = Queue::at(tmp.path().join("queue"));
1261 (
1262 Fx {
1263 _tmp: tmp,
1264 repo,
1265 runs,
1266 q,
1267 },
1268 base,
1269 tip,
1270 )
1271 }
1272
1273 fn write_run(f: &Fx, id: &str, status: &str, base: &str, pr: Option<(u64, &str)>) {
1274 let dir = f.runs.join(id);
1275 std::fs::create_dir_all(&dir).unwrap();
1276 let pr = pr.map(|(n, s)| {
1277 serde_json::json!({"url": format!("https://github.com/o/r/pull/{n}"), "number": n, "state": s})
1278 });
1279 let v = serde_json::json!({
1280 "schema": 999, "id": id, "repo": f.repo, "status": status,
1281 "base_commit": base, "candidates": [{"branch": "magi/aaaa/A"}], "pr": pr,
1282 });
1283 std::fs::write(dir.join("run.json"), v.to_string()).unwrap();
1284 }
1285
1286 fn file_task(f: &Fx, status: TaskStatus, runs: &[&str]) -> Task {
1287 let mut t = Task::new("t".into(), "x".into(), f.repo.clone(), Source::Human);
1288 t.status = status;
1289 t.runs = runs.iter().map(|s| (*s).to_owned()).collect();
1290 f.q.put(&mut t).unwrap();
1291 t
1292 }
1293
1294 const RID: &str = "20260901-100000-aaaa";
1295
1296 fn run(f: &Fx, text: &str, review: Option<&str>) -> Vec<Hit> {
1297 check_with(
1298 &f.q,
1299 &f.runs,
1300 &f.repo,
1301 text,
1302 review,
1303 None,
1304 &|_, _| None,
1305 &|_, _| None,
1306 )
1307 }
1308
1309 #[test]
1310 fn branch_matches_a_live_run_and_its_task() {
1311 let (f, base, _) = fx();
1312 write_run(&f, RID, "reviewing", &base, None);
1313 let t = file_task(&f, TaskStatus::Running, &[RID]);
1314 let hits = run(&f, "land magi/aaaa/A onto a fresh branch.", None);
1315 assert!(hits.iter().any(|h| h.owner == Owner::Task
1316 && h.id == t.id
1317 && h.signal == Signal::Branch
1318 && h.token == "magi/aaaa/A"));
1319 assert!(hits.iter().any(|h| h.owner == Owner::Run && h.id == RID));
1320 let msg = Duplicate::new(hits).to_string();
1321 assert!(
1322 msg.contains("--force") && msg.contains("magi/aaaa/A"),
1323 "{msg}"
1324 );
1325 }
1326
1327 #[test]
1328 fn branch_must_match_whole_word() {
1329 let (f, base, _) = fx();
1330 write_run(&f, RID, "reviewing", &base, None);
1331 assert!(run(&f, "see magi/aaaa/AB and magi/aaaa/A/x", None).is_empty());
1332 }
1333
1334 #[test]
1335 fn sha_on_the_branch_matches_but_one_in_base_does_not() {
1336 let (f, base, tip) = fx();
1337 write_run(&f, RID, "reviewing", &base, None);
1338 let hits = run(&f, &format!("land commit {} please", &tip[..8]), None);
1339 assert!(
1340 hits.iter().any(|h| h.signal == Signal::Sha && h.id == RID),
1341 "{hits:?}"
1342 );
1343 assert!(run(&f, &format!("see {}", &base[..9]), None).is_empty());
1344 assert!(run(&f, "deadbeef and 1234567", None).is_empty());
1346 }
1347
1348 #[test]
1349 fn pr_number_matches_in_every_spelling() {
1350 let (f, base, _) = fx();
1351 write_run(&f, RID, "ready", &base, Some((48, "open")));
1352 for text in [
1353 "finish #48",
1354 "PR 48 is stale",
1355 "pr #48",
1356 "pull request 48",
1357 "https://github.com/o/r/pull/48",
1358 ] {
1359 let hits = run(&f, text, None);
1360 assert!(
1361 hits.iter().any(|h| h.signal == Signal::Pr),
1362 "{text}: {hits:?}"
1363 );
1364 }
1365 assert!(run(&f, "see #480 and PR 4 and issue48", None).is_empty());
1366 }
1367
1368 #[test]
1369 fn terminal_runs_and_done_tasks_do_not_match() {
1370 let (f, base, tip) = fx();
1371 write_run(&f, RID, "merged", &base, Some((48, "merged")));
1372 file_task(&f, TaskStatus::Done, &[RID]);
1373 let text = format!("magi/aaaa/A {} #48", &tip[..8]);
1374 assert!(run(&f, &text, None).is_empty());
1375 }
1376
1377 #[test]
1378 fn terminal_run_with_open_pr_or_open_task_still_claims() {
1379 let (f, base, _) = fx();
1384 write_run(&f, RID, "ready", &base, Some((48, "open")));
1385 assert!(!run(&f, "magi/aaaa/A", None).is_empty());
1386 let (g, base, _) = fx();
1387 write_run(&g, RID, "ready", &base, None);
1388 assert!(run(&g, "magi/aaaa/A", None).is_empty());
1389 let t = file_task(&g, TaskStatus::Held, &[RID]);
1390 let hits = run(&g, "magi/aaaa/A", None);
1391 assert!(hits.iter().any(|h| h.id == t.id), "{hits:?}");
1392 }
1393
1394 #[test]
1395 fn review_only_matches_a_branch_a_live_task_owns() {
1396 let (f, base, _) = fx();
1397 write_run(&f, RID, "ready", &base, None);
1398 assert!(run(&f, "", Some("magi/aaaa/A")).is_empty());
1399 let mut t = file_task(&f, TaskStatus::Queued, &[]);
1400 t.review_branch = Some("magi/aaaa/A".into());
1401 f.q.put(&mut t).unwrap();
1402 let hits = run(&f, "", Some("magi/aaaa/A"));
1403 assert!(
1404 hits.iter()
1405 .any(|h| h.id == t.id && h.signal == Signal::Branch)
1406 );
1407 }
1408
1409 #[test]
1410 fn other_repository_and_edited_task_do_not_match() {
1411 let (f, base, _) = fx();
1412 write_run(&f, RID, "reviewing", &base, None);
1413 let t = file_task(&f, TaskStatus::Running, &[RID]);
1414 let other = f._tmp.path().join("other");
1415 std::fs::create_dir_all(&other).unwrap();
1416 sh(&other, &["init", "-q"]);
1417 assert!(
1418 check_with(
1419 &f.q,
1420 &f.runs,
1421 &other,
1422 "magi/aaaa/A",
1423 None,
1424 None,
1425 &|_, _| None,
1426 &|_, _| None,
1427 )
1428 .is_empty()
1429 );
1430 assert!(
1432 check_with(
1433 &f.q,
1434 &f.runs,
1435 &f.repo,
1436 "magi/aaaa/A",
1437 None,
1438 Some(&t.id),
1439 &|_, _| None,
1440 &|_, _| None,
1441 )
1442 .is_empty()
1443 );
1444 }
1445
1446 #[test]
1447 fn a_worktree_is_the_same_repository() {
1448 let (f, base, _) = fx();
1449 write_run(&f, RID, "reviewing", &base, None);
1450 let wt = f._tmp.path().join("wt");
1451 sh(
1452 &f.repo,
1453 &["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "other"],
1454 );
1455 assert!(
1456 !check_with(
1457 &f.q,
1458 &f.runs,
1459 &wt,
1460 "magi/aaaa/A",
1461 None,
1462 None,
1463 &|_, _| None,
1464 &|_, _| None,
1465 )
1466 .is_empty()
1467 );
1468 }
1469
1470 #[test]
1471 fn an_open_pr_without_a_run_record_matches_through_the_forge() {
1472 let (f, _, _) = fx();
1473 let open = |_: &Path, n: u64| (n == 48).then(|| "https://example.test/pull/48".to_owned());
1474 let hit = |text: &str| {
1475 check_with(&f.q, &f.runs, &f.repo, text, None, None, &open, &|_, _| {
1476 None
1477 })
1478 };
1479 let hits = hit("finish PR #48");
1480 assert_eq!(hits.len(), 1, "{hits:?}");
1481 assert_eq!(hits[0].owner, Owner::Pr);
1482 assert!(hits[0].to_string().contains("#48"));
1483 assert!(hit("finish PR #49").is_empty());
1485 assert!(hit("finish the work").is_empty());
1486 }
1487
1488 #[test]
1489 fn a_forge_hit_does_not_repeat_a_pr_a_run_already_explains() {
1490 let (f, base, _) = fx();
1491 write_run(&f, RID, "ready", &base, Some((48, "open")));
1492 let open = |_: &Path, _: u64| Some("u".to_owned());
1493 let hits = check_with(&f.q, &f.runs, &f.repo, "#48", None, None, &open, &|_, _| {
1494 None
1495 });
1496 assert!(hits.iter().all(|h| h.owner != Owner::Pr), "{hits:?}");
1497 assert!(!hits.is_empty());
1498 }
1499
1500 #[test]
1501 fn an_edited_tasks_own_open_pr_is_not_a_forge_hit() {
1502 let (f, base, _) = fx();
1503 write_run(&f, RID, "ready", &base, Some((48, "open")));
1504 let t = file_task(&f, TaskStatus::Running, &[RID]);
1505 let open = |_: &Path, _: u64| Some("u".to_owned());
1506 let hits = check_with(
1507 &f.q,
1508 &f.runs,
1509 &f.repo,
1510 "#48",
1511 None,
1512 Some(&t.id),
1513 &open,
1514 &|_, _| None,
1515 );
1516 assert!(hits.is_empty(), "{hits:?}");
1517 }
1518
1519 fn with_forge(f: &Fx, text: &str, state: Option<PrLifecycle>) -> Vec<Hit> {
1520 check_with(
1521 &f.q,
1522 &f.runs,
1523 &f.repo,
1524 text,
1525 None,
1526 None,
1527 &|_, _| None,
1528 &move |_, _| state,
1529 )
1530 }
1531
1532 fn release(f: &Fx, id: &str, to: &str) {
1533 let path = f.runs.join(id).join("run.json");
1534 let mut v: serde_json::Value =
1535 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1536 v["released_to"] = serde_json::json!(to);
1537 std::fs::write(path, v.to_string()).unwrap();
1538 }
1539
1540 #[test]
1541 fn a_task_public_head_and_pr_are_claimed_only_by_the_successor() {
1542 let (f, base, _) = fx();
1543 write_run(&f, RID, "blocked", &base, Some((48, "open")));
1544 let next = "20260901-000001-bbbb";
1545 write_run(&f, next, "landing", &base, Some((48, "open")));
1546 for id in [RID, next] {
1547 let path = f.runs.join(id).join("run.json");
1548 let mut value: serde_json::Value =
1549 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1550 value["pr_head"] = serde_json::json!("magi/task-abcd-1234");
1551 if id == RID {
1552 value["pr_taken_over_by"] = serde_json::json!(next);
1553 }
1554 std::fs::write(path, value.to_string()).unwrap();
1555 }
1556 for text in ["update #48", "update magi/task-abcd-1234"] {
1557 let hits = with_forge(&f, text, Some(PrLifecycle::Open));
1558 assert!(!hits.is_empty());
1559 assert!(hits.iter().all(|h| h.id == next), "{hits:?}");
1560 }
1561 let task = file_task(&f, TaskStatus::Held, &[RID, next]);
1562 let hits = with_forge(&f, "update magi/task-abcd-1234", Some(PrLifecycle::Open));
1563 assert_eq!(hits.len(), 1, "{hits:?}");
1564 assert_eq!(hits[0].id, task.id);
1565 }
1566
1567 #[test]
1568 fn stale_open_pr_that_the_forge_says_is_merged_or_closed_does_not_claim() {
1569 for state in [PrLifecycle::Merged, PrLifecycle::Closed] {
1570 let (f, base, _) = fx();
1571 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1572 assert!(with_forge(&f, "follow up on #48", Some(state)).is_empty());
1573 assert!(with_forge(&f, "magi/aaaa/A", Some(state)).is_empty());
1574 file_task(&f, TaskStatus::Held, &[RID]);
1576 let hits = with_forge(&f, "follow up on #48", Some(state));
1577 assert!(hits.iter().all(|h| h.signal != Signal::Pr), "{hits:?}");
1578 }
1579 }
1580
1581 #[test]
1582 fn stale_open_pr_with_an_unreadable_forge_still_claims() {
1583 let (f, base, _) = fx();
1584 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1585 let hits = with_forge(&f, "follow up on #48", None);
1586 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1587 }
1588
1589 #[test]
1590 fn a_genuinely_open_pr_still_claims() {
1591 let (f, base, _) = fx();
1592 write_run(&f, RID, "blocked", &base, Some((48, "open")));
1593 let hits = with_forge(&f, "follow up on #48", Some(PrLifecycle::Open));
1594 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1595 }
1596
1597 #[test]
1598 fn a_released_run_defers_to_its_successor_without_asking_the_forge() {
1599 let (f, base, _) = fx();
1600 let next = "20260901-110000-bbbb";
1601 write_run(&f, RID, "superseded", &base, Some((48, "open")));
1602 write_run(&f, next, "merged", &base, Some((48, "merged")));
1603 release(&f, RID, next);
1604 let asked = std::cell::Cell::new(0);
1605 let hits = check_with(
1606 &f.q,
1607 &f.runs,
1608 &f.repo,
1609 "follow up on #48",
1610 None,
1611 None,
1612 &|_, _| None,
1613 &|_, _| {
1614 asked.set(asked.get() + 1);
1615 None
1616 },
1617 );
1618 assert!(hits.is_empty(), "{hits:?}");
1619 assert_eq!(asked.get(), 0);
1620 std::fs::remove_dir_all(f.runs.join(next)).unwrap();
1622 let hits = with_forge(&f, "follow up on #48", None);
1623 assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1624 }
1625
1626 #[test]
1627 fn forge_lookups_are_cached_and_stop_after_a_failure() {
1628 let (f, base, _) = fx();
1629 for (i, id) in ["20260901-100000-aaa1", "20260901-100000-aaa2"]
1630 .iter()
1631 .enumerate()
1632 {
1633 write_run(&f, id, "blocked", &base, Some((48 + i as u64, "open")));
1634 }
1635 let asked = std::cell::Cell::new(0);
1636 check_with(
1637 &f.q,
1638 &f.runs,
1639 &f.repo,
1640 "x",
1641 None,
1642 None,
1643 &|_, _| None,
1644 &|_, _| {
1645 asked.set(asked.get() + 1);
1646 None
1647 },
1648 );
1649 assert_eq!(
1650 asked.get(),
1651 1,
1652 "an unreadable forge is asked once, not per PR"
1653 );
1654 }
1655
1656 use std::sync::Arc;
1657 use std::sync::atomic::{AtomicUsize, Ordering};
1658
1659 fn verdict(duplicate: bool) -> Result<Judgement> {
1660 Ok(Judgement {
1661 duplicate,
1662 reason: "because".into(),
1663 agent: "j".into(),
1664 })
1665 }
1666
1667 fn judged(
1670 f: &Fx,
1671 text: &str,
1672 answer: impl Fn() -> Result<Judgement> + Send + Sync + 'static,
1673 ) -> (Result<Screened, Duplicate>, usize) {
1674 let calls = Arc::new(AtomicUsize::new(0));
1675 let seen = calls.clone();
1676 let judge = move |_: String, _: Vec<Hit>| -> JudgeFuture {
1677 seen.fetch_add(1, Ordering::SeqCst);
1678 let r = answer();
1679 Box::pin(async move { r })
1680 };
1681 let hits = run(f, text, None);
1682 let out = tokio::runtime::Builder::new_current_thread()
1683 .build()
1684 .unwrap()
1685 .block_on(screen(hits, text, None, &judge));
1686 (out, calls.load(Ordering::SeqCst))
1687 }
1688
1689 const NAMES: &str = "seen on magi/aaaa/A, which does not touch this file";
1690
1691 fn fx_with_run() -> Fx {
1692 let (f, base, _) = fx();
1693 write_run(&f, RID, "reviewing", &base, None);
1694 f
1695 }
1696
1697 #[test]
1698 fn a_no_ruling_lets_the_work_through() {
1699 let f = fx_with_run();
1700 let (out, calls) = judged(&f, NAMES, || verdict(false));
1701 assert!(matches!(out, Ok(Screened::Cleared(_))));
1702 assert_eq!(calls, 1);
1703 }
1704
1705 #[test]
1706 fn a_yes_ruling_refuses_and_says_why() {
1707 let f = fx_with_run();
1708 let (out, _) = judged(&f, NAMES, || verdict(true));
1709 let msg = out.unwrap_err().to_string();
1710 assert!(
1711 msg.contains("duplicate - because") && msg.contains("--force"),
1712 "{msg}"
1713 );
1714 assert!(msg.contains("magi/aaaa/A"), "{msg}");
1715 }
1716
1717 #[test]
1718 fn a_failing_judge_lets_it_through_unjudged() {
1719 let f = fx_with_run();
1720 let (out, calls) = judged(&f, NAMES, || Err(anyhow::anyhow!("quota")));
1721 match out {
1722 Ok(Screened::Unjudged(why)) => assert!(why.contains("quota"), "{why}"),
1723 other => panic!("{other:?}"),
1724 }
1725 assert_eq!(calls, 1);
1726 }
1727
1728 #[test]
1729 fn garbage_replies_do_not_parse() {
1730 assert!(parse_judgement("sure, go ahead").is_err());
1731 assert!(parse_judgement(r#"{"duplicate":"maybe","reason":"x"}"#).is_err());
1732 assert!(parse_judgement(r#"{"duplicate":false}"#).is_err());
1733 assert!(parse_judgement(r#"{"duplicate":false,"reason":" "}"#).is_err());
1734 let two = "{\"duplicate\":false,\"reason\":\"c\"}\n{\"duplicate\":true}";
1735 assert!(parse_judgement(two).is_err());
1736 assert!(parse_judgement("ok {\"duplicate\":false,\"reason\":\"c\"}").is_err());
1737 let (d, why) = parse_judgement("{\"duplicate\":true,\"reason\":\"same\\nPR\"}").unwrap();
1738 assert_eq!((d, why.as_str()), (true, "same PR"));
1739 }
1740
1741 #[test]
1742 fn a_text_too_long_is_squeezed_and_still_judged() {
1743 let f = fx_with_run();
1744 let long = format!("{NAMES} {}", "x".repeat(prompt::DUPES_JUDGE_MAX_CHARS));
1745 let (out, calls) = judged(&f, &long, || verdict(false));
1746 assert!(matches!(out, Ok(Screened::Cleared(_))));
1747 assert_eq!(calls, 1);
1748 }
1749
1750 #[test]
1751 fn no_hit_never_asks_the_judge() {
1752 let (f, _, _) = fx();
1753 let (out, calls) = judged(&f, "nothing named here", || verdict(true));
1754 assert_eq!(out.unwrap(), Screened::Clean);
1755 assert_eq!(calls, 0);
1756 }
1757
1758 #[test]
1759 fn without_a_config_a_hit_passes_unjudged() {
1760 let f = fx_with_run();
1761 let hits = run(&f, NAMES, None);
1762 let out = tokio::runtime::Builder::new_current_thread()
1763 .build()
1764 .unwrap()
1765 .block_on(screen_with_config(hits, NAMES, None, &f.repo, None));
1766 assert!(matches!(out, Ok(Screened::Unjudged(_))));
1767 }
1768
1769 #[test]
1770 fn about_carries_title_and_whole_instruction() {
1771 assert_eq!(
1772 about_of("Retries", "# Task\nFix auth\nretries"),
1773 "Retries: # Task Fix auth retries"
1774 );
1775 assert_eq!(about_of("", "x"), "x");
1776 let mid = format!("{} MIDDLE {}", "a ".repeat(2000), "b ".repeat(500));
1777 assert!(about_of("", &mid).contains("MIDDLE"));
1778 let long = format!("{} TAIL", "y ".repeat(5000));
1779 let a = about_of("", &long);
1780 assert!(a.ends_with("TAIL") && a.chars().count() < 6100, "{a}");
1781 }
1782}