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