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