Skip to main content

magi/
dupes.rs

1//! Duplicate-work detection at the moment work is filed.
2//!
3//! Two tasks once chased the same commit without knowing about each other: an
4//! open task and its open pull request already covered a change when a chat
5//! filed a second task that named the same branch and commit. Nothing at
6//! filing time said so. This module is that check, and it is deliberately
7//! small: **concrete identifiers only** - a branch name, a commit SHA, a pull
8//! request number - never text similarity. A false positive costs one
9//! `--force`; a false negative is the behaviour before this module existed.
10//!
11//! A *claim* is a thing an unfinished piece of work owns: the branches of its
12//! candidates and the pull request it opened. The claims come from
13//!
14//! - every task that is not `done`: its `review_branch`, and each run in
15//!   `Task::runs`;
16//! - every run that has not reached a terminal status;
17//! - every run that is terminal but whose pull request is still open (the
18//!   shape of the original accident: the run ended, the PR did not).
19//!
20//! Run records are read through a tolerant view rather than `RunState::load`:
21//! a schema bump must not turn into a silent false negative (the same lesson
22//! as `clean::fold_due`).
23//!
24//! Everything here is synchronous, read-only and takes its roots as
25//! arguments, so tests need no process-global home.
26
27use 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/// What kind of identifier matched.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum Signal {
50    /// A branch name.
51    Branch,
52    /// A commit SHA.
53    Sha,
54    /// A pull request number or URL.
55    Pr,
56}
57
58/// Who owns the thing that matched.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Owner {
61    /// A queued task.
62    Task,
63    /// A recorded run.
64    Run,
65    /// A pull request the forge reports open that no record here owns.
66    Pr,
67}
68
69/// One reason a new piece of work looks like one already under way.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Hit {
72    /// Task or run.
73    pub owner: Owner,
74    /// Full id of the task or run.
75    pub id: String,
76    /// The owner's status word (`queued`, `reviewing`, ...).
77    pub status: String,
78    /// What kind of identifier matched.
79    pub signal: Signal,
80    /// The identifier as it matched: a branch, a SHA, `#48`.
81    pub token: String,
82    /// How the owner is tied to it, e.g. `produced by its run c9eb`.
83    pub via: String,
84    /// The owner's title and instruction (collapsed, cut), for the
85    /// judge only: never shown in [`Display`]. Empty when no record has it.
86    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/// The refusal: one or more [`Hit`]s and nothing filed.
113#[derive(Debug, Clone)]
114pub struct Duplicate {
115    /// What matched.
116    pub hits: Vec<Hit>,
117    /// The judge's word, when one was asked: a ready-made line for the
118    /// refusal text. `None` when no judge ran.
119    pub judge: Option<String>,
120}
121
122impl Duplicate {
123    /// A refusal on the identifier match alone.
124    pub fn new(hits: Vec<Hit>) -> Self {
125        Self { hits, judge: None }
126    }
127
128    /// The refusal text, ending in how to override it.
129    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/// The judge's answer, with the agent that gave it.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Judgement {
159    /// True when the new work is genuinely a duplicate of the matched claims.
160    pub duplicate: bool,
161    /// One line, as the agent wrote it.
162    pub reason: String,
163    /// Id of the agent that answered.
164    pub agent: String,
165}
166
167/// How [`screen`] let work through.
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub enum Screened {
170    /// No identifier matched; no judge was asked.
171    Clean,
172    /// A match was found and the judge said it is not a duplicate.
173    Cleared(Judgement),
174    /// A match was found and nobody could judge it (the reason): let through.
175    Unjudged(String),
176}
177
178/// A judge: given the instruction and the matched claims, rules on them.
179/// Owned arguments, so a boxed future needs no borrow.
180pub type JudgeFuture = Pin<Box<dyn Future<Output = Result<Judgement>> + Send>>;
181
182/// Whole-chain wall-clock budget for the judge call.
183const JUDGE_BUDGET: Duration = Duration::from_secs(45);
184/// Per-agent cap inside that budget.
185const JUDGE_TURN: Duration = Duration::from_secs(30);
186
187/// Read the judge's reply. Strict: anything but one JSON object with a bool
188/// `duplicate` and a non-empty reason is an error (the caller lets the work
189/// through unjudged, see [`screen`]).
190fn parse_judgement(text: &str) -> Result<(bool, String)> {
191    #[derive(Deserialize)]
192    struct Raw {
193        duplicate: bool,
194        reason: String,
195    }
196    // The whole reply must be one object (optionally in a code fence): hunting
197    // for an object inside prose or a second answer could recover a verdict
198    // from a reply that is not itself a single valid one.
199    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
213/// Ask the `[roles] dupes_judge` chain, or inherit `chatter` when unset.
214/// With neither role set, use the default agent. Each agent is
215/// tried at most once, with a fresh seat; only an error, quota or unusable
216/// answer moves on. A usable answer that does not parse ends the chain.
217pub 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            fetched_base: None,
250            cwd: repo,
251            prompt: &body,
252            timeout: left.min(JUDGE_TURN),
253            allow_write: 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
289/// The one decision point every caller shares. `hits` is [`check`]'s output:
290/// empty passes without the judge being asked. Otherwise the judge is asked
291/// once (a long text is squeezed to its head and tail first), and only a `duplicate: true` answer refuses (with its reason).
292///
293/// **Fail mode: open.** If the judge cannot answer (agent error, timeout,
294/// quota, unparseable reply, no readable config) the work is let through as [`Screened::Unjudged`] and a
295/// `tracing::warn` is logged; callers also say so on stderr. A mechanical
296/// match cannot tell "continue from PR #28" from a real duplicate, so refusing
297/// whenever the judge is away would bring back exactly the false positives the
298/// judge exists to remove. The cost is that a real duplicate slips through
299/// while no agent is available, which is why it is never silent.
300///
301/// For a review-only request `text` is empty, so `review_branch` is put in
302/// front of the judge as the thing the work is about.
303pub 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
343/// [`screen`] with the production judge: `dupes_judge`, inheriting `chatter` when unset.
344/// No config (`None`) means no judge, so a hit is let through unjudged.
345pub 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/// The slice of `run.json` this module needs. Every field is optional so a
376/// record from any schema still yields what it has.
377#[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    foreign_checkout: Option<CandView>,
393    #[serde(default)]
394    pr: Option<PrView>,
395    /// The run that took this one's worktree (and pull request) over.
396    #[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    /// An unknown status word counts as unfinished: claiming too much is the
427    /// cheap error here.
428    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
439/// Decides whether the pull request a run's record still calls open is in fact
440/// settled, so a stale record stops claiming it.
441///
442/// A record freezes the last state its land loop polled. A run that was handed
443/// over (`released_to`) or left blocked while another run landed the same pull
444/// request keeps saying `open` forever, so the record alone cannot be trusted
445/// to *keep* a claim alive. Two answers, cheapest first:
446///
447/// 1. a released run does not own the pull request any more: ownership follows
448///    `released_to`, and the successor's own record (read like any other run's)
449///    decides - provided that record is readable;
450/// 2. otherwise the forge is asked, once per number, at most
451///    [`MAX_FORGE_LOOKUPS`] numbers, and never again after one lookup failed
452///    (offline stays fast). Merged or closed means no claim; open, an error or
453///    a skipped lookup means the claim stands. Claiming too much is the cheap
454///    error.
455struct 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
463/// How many pull requests one invocation may put to the forge on behalf of
464/// stale records.
465const 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    /// `true` when `view`'s recorded-open pull request is known not to be this
479    /// run's to claim any more.
480    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/// Something an unfinished piece of work owns.
520#[derive(Debug, Clone)]
521struct Claim {
522    owner: Owner,
523    id: String,
524    status: String,
525    via: String,
526    /// The owner's title and instruction, for the judge.
527    about: String,
528    branch: Option<String>,
529    /// Commit the branch forked from; without it a SHA cannot be judged.
530    base: Option<String>,
531    /// `(number, url)` of an open pull request.
532    pr: Option<(u64, String)>,
533}
534
535/// Look for work already in flight that `text` (and `review_branch`, for a
536/// review-only run) points at. `repo` is the repository the new work is for;
537/// `ignore_task` is a task being edited, whose own claims never count.
538pub 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/// [`check`] with the forge lookup supplied: `open_pr(repo, n)` says whether
559/// pull request `n` of `repo` is open (`Some(url)`) or not / unknown (`None`).
560/// It is asked only about numbers the text names that no local record already
561/// explained, so a PR magi never produced (opened by hand) still collides.
562/// `pr_state(repo, n)` is the forge's word on a pull request a run record calls
563/// open (`None` = unreadable, which keeps the claim); see [`Staleness`].
564#[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    // PRs the edited task's own runs opened: never a rival, forge or not.
585    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        // A task-scoped publication is one task's hand-off, even when
608        // several run records retain its history.
609        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        // A terminal run held up only by a pull request that is not its own
617        // (any more) claims nothing at all.
618        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
699/// Ask the forge, via `gh`, whether PR `n` is open. Best effort and bounded:
700/// any failure (no `gh`, no remote, offline, a slow answer) is `None`, i.e.
701/// today's behaviour. `GH_REPO` is dropped so the PR is looked up in `repo`'s
702/// own remote, not whatever the environment points at.
703fn 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
710/// [`gh_open_pr`]'s question asked of a pull request a run record names: its
711/// lifecycle, or `None` when the forge cannot say.
712fn 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
751/// Fill in what a forge-only hit (an open pull request no record here owns)
752/// is about, from its title and body. Only the judge reads it, so this runs
753/// after the mechanical check, only on a hit, and best effort: an unreadable
754/// forge leaves `about` empty, never drops the hit.
755fn 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
772/// Keep the head and the tail of `s` within `max` characters, marking the cut.
773/// A long instruction often has background first and the concrete change last,
774/// so dropping only the end would lose exactly what the judge compares.
775fn 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
789/// The owner's work for the judge: its title (when it has one) and its
790/// instruction with whitespace collapsed, keeping the head and the tail when
791/// it is long. The whole instruction is used, not its first line: a heading
792/// such as `# Task` says nothing about what the work is.
793fn 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
804/// Longest description of one owner's work given to the judge: the same limit
805/// as the new text, so an instruction the judge accepts on one side is not
806/// cut on the other.
807const 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            // A merged or closed pull request is not in flight, even when the
834            // task that owns it is.
835            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
852/// The claims of one run, attributed to `owner` (the task, when there is one).
853///
854/// `live_pr` is false when the record's open pull request is known to be
855/// settled or somebody else's (see [`Staleness`]); it then names no PR.
856fn 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        .chain(view.foreign_checkout.iter())
882        .filter(|c| !c.branch.is_empty())
883        .map(|c| Claim {
884            owner: owner.clone(),
885            id: id.clone(),
886            status: status.clone(),
887            via: via_pr(""),
888            about: about.clone(),
889            branch: Some(c.branch.clone()),
890            base: (!view.base_commit.is_empty()).then(|| view.base_commit.clone()),
891            pr: None,
892        })
893        .collect();
894    // A run whose own pull request already merged or closed no longer holds
895    // the task head; it is history, not an owner (it is never superseded).
896    let settled = view
897        .pr
898        .as_ref()
899        .is_some_and(|p| p.state == "merged" || p.state == "closed");
900    if live_pr
901        && !settled
902        && let Some(branch) = &view.pr_head
903    {
904        out.push(Claim {
905            owner: owner.clone(),
906            id: id.clone(),
907            status: status.clone(),
908            via: via_pr(""),
909            about: about.clone(),
910            branch: Some(branch.clone()),
911            base: None,
912            pr: None,
913        });
914    }
915    if pr.is_some() {
916        out.push(Claim {
917            owner,
918            id,
919            status,
920            via: via_pr(""),
921            about,
922            branch: None,
923            base: None,
924            pr,
925        });
926    }
927    out
928}
929
930/// Repository identity: the canonical git common dir, so two worktrees of one
931/// repository are the same repository and a path spelled two ways is too.
932#[derive(Default)]
933struct Idents(HashMap<PathBuf, PathBuf>);
934
935impl Idents {
936    fn of(&mut self, path: &Path) -> PathBuf {
937        self.0
938            .entry(path.to_path_buf())
939            .or_insert_with(|| {
940                git(
941                    path,
942                    &["rev-parse", "--path-format=absolute", "--git-common-dir"],
943                )
944                .map(PathBuf::from)
945                .and_then(|p| p.canonicalize().ok())
946                .or_else(|| path.canonicalize().ok())
947                .unwrap_or_else(|| path.to_path_buf())
948            })
949            .clone()
950    }
951}
952
953fn git(cwd: &Path, args: &[&str]) -> Option<String> {
954    let out = Command::new("git")
955        .quiet()
956        .args(args)
957        .current_dir(cwd)
958        .stdin(Stdio::null())
959        .stderr(Stdio::null())
960        .env("GIT_TERMINAL_PROMPT", "0")
961        .output()
962        .ok()?;
963    out.status
964        .success()
965        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
966}
967
968fn git_ok(cwd: &Path, args: &[&str]) -> bool {
969    git(cwd, args).is_some()
970}
971
972fn is_ref_char(c: char) -> bool {
973    c.is_alphanumeric() || matches!(c, '_' | '-')
974}
975
976/// Does `text` contain `branch` as a whole word? `.` and `/` before it are
977/// fine (`origin/magi/x/A`), a name character on either side is not, so
978/// `magi/27b2/A` does not match inside `magi/27b2/AB`.
979fn names_branch(text: &str, branch: &str) -> bool {
980    if branch.len() < 3 {
981        return false;
982    }
983    text.match_indices(branch).any(|(i, _)| {
984        let before = text[..i].chars().next_back();
985        let after = text[i + branch.len()..].chars().next();
986        let after_ok = match after {
987            None => true,
988            Some('/') => false,
989            Some('.') => !text[i + branch.len() + 1..]
990                .chars()
991                .next()
992                .is_some_and(is_ref_char),
993            Some(c) => !is_ref_char(c),
994        };
995        before.is_none_or(|c| !is_ref_char(c) && c != '.') && after_ok
996    })
997}
998
999#[derive(Debug, PartialEq, Eq)]
1000enum Mention {
1001    Number(u64),
1002    Url(String),
1003}
1004
1005/// `#48`, `PR 48`, `PR #48`, `pull request 48`, and `.../pull/48` URLs.
1006fn pr_numbers(text: &str) -> Vec<Mention> {
1007    let mut out = Vec::new();
1008    let bytes = text.as_bytes();
1009    let digits = |from: usize| -> Option<(u64, usize)> {
1010        let n = text[from..].bytes().take_while(u8::is_ascii_digit).count();
1011        (n > 0 && n < 10)
1012            .then(|| text[from..from + n].parse().ok().map(|v| (v, from + n)))
1013            .flatten()
1014    };
1015    for (i, _) in text.match_indices('#') {
1016        if let Some((n, _)) = digits(i + 1) {
1017            let word_before = i > 0 && is_ref_char(bytes[i - 1] as char);
1018            if !word_before {
1019                out.push(Mention::Number(n));
1020            }
1021        }
1022    }
1023    let lower = text.to_ascii_lowercase();
1024    for key in ["pull request ", "pr "] {
1025        for (i, _) in lower.match_indices(key) {
1026            if i > 0 && is_ref_char(bytes[i - 1] as char) {
1027                continue;
1028            }
1029            let from = i + key.len();
1030            let from = if text[from..].starts_with('#') {
1031                from + 1
1032            } else {
1033                from
1034            };
1035            if let Some((n, _)) = digits(from) {
1036                out.push(Mention::Number(n));
1037            }
1038        }
1039    }
1040    for (i, _) in text.match_indices("/pull/") {
1041        if let Some((_, end)) = digits(i + 6) {
1042            let start = text[..i]
1043                .rfind(|c: char| c.is_whitespace() || matches!(c, '(' | '<' | '"' | '\''))
1044                .map_or(0, |p| p + 1);
1045            out.push(Mention::Url(text[start..end].to_owned()));
1046        }
1047    }
1048    out
1049}
1050
1051/// Hex words of 7..=40 chars in `text` that resolve to a commit in `repo`.
1052fn sha_candidates(repo: &Path, text: &str) -> Vec<String> {
1053    let mut seen = BTreeSet::new();
1054    let mut out = Vec::new();
1055    for word in text.split(|c: char| !c.is_ascii_alphanumeric()) {
1056        if !(7..=40).contains(&word.len()) || !word.bytes().all(|b| b.is_ascii_hexdigit()) {
1057            continue;
1058        }
1059        if seen.len() >= 16 || !seen.insert(word.to_ascii_lowercase()) {
1060            continue;
1061        }
1062        if let Some(full) = git(
1063            repo,
1064            &[
1065                "rev-parse",
1066                "--verify",
1067                "--quiet",
1068                &format!("{word}^{{commit}}"),
1069            ],
1070        ) {
1071            out.push(full);
1072        }
1073    }
1074    out
1075}
1076
1077/// Is `sha` reachable from `branch` but not from `base`? A SHA already in the
1078/// base would match every branch there is.
1079fn on_branch_only(repo: &Path, sha: &str, branch: &str, base: &str) -> bool {
1080    let tip = format!("{branch}^{{commit}}");
1081    git_ok(repo, &["rev-parse", "--verify", "--quiet", &tip])
1082        && git_ok(repo, &["merge-base", "--is-ancestor", sha, branch])
1083        && !git_ok(repo, &["merge-base", "--is-ancestor", sha, base])
1084}
1085
1086fn short_sha(sha: &str) -> String {
1087    sha.chars().take(7).collect()
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092    use super::*;
1093    use crate::queue::{Source, Task};
1094
1095    #[cfg(unix)]
1096    fn judge_agent(id: &str, fails: bool) -> crate::config::AgentSpec {
1097        crate::config::AgentSpec {
1098            id: id.to_owned(),
1099            kind: crate::config::AgentKind::Command,
1100            model: None,
1101            command: vec![
1102                "sh".into(),
1103                "-c".into(),
1104                format!(
1105                    "echo {id} >> calls; {}",
1106                    if fails {
1107                        "exit 1"
1108                    } else {
1109                        r#"printf '%s' '{"duplicate":false,"reason":"different work"}'"#
1110                    }
1111                ),
1112            ],
1113            extra_args: Vec::new(),
1114            env: Default::default(),
1115            prompt_delivery: None,
1116            max_turn_secs: None,
1117        }
1118    }
1119
1120    #[cfg(unix)]
1121    fn judge_config() -> Config {
1122        let mut cfg = Config {
1123            agents: vec![
1124                judge_agent("chat", false),
1125                judge_agent("dedicated", false),
1126                judge_agent("failed", true),
1127            ],
1128            ..Config::default()
1129        };
1130        cfg.roles.chatter = Some("chat".into());
1131        cfg
1132    }
1133
1134    #[cfg(unix)]
1135    #[tokio::test]
1136    async fn dupes_judge_overrides_chatter() {
1137        let dir = tempfile::tempdir().unwrap();
1138        let mut cfg = judge_config();
1139        cfg.roles.dupes_judge = Some("dedicated".into());
1140        let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1141            .await
1142            .unwrap();
1143        assert_eq!(verdict.agent, "dedicated");
1144        assert!(!verdict.duplicate);
1145        assert_eq!(
1146            std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1147            "dedicated\n"
1148        );
1149        assert_eq!(cfg.roles.chatter.as_ref().unwrap().ids(), ["chat"]);
1150    }
1151
1152    #[cfg(unix)]
1153    #[tokio::test]
1154    async fn dupes_judge_unset_inherits_chatter() {
1155        let dir = tempfile::tempdir().unwrap();
1156        let cfg = judge_config();
1157        let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1158            .await
1159            .unwrap();
1160        assert_eq!(verdict.agent, "chat");
1161        assert_eq!(
1162            std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1163            "chat\n"
1164        );
1165    }
1166
1167    #[cfg(unix)]
1168    #[tokio::test]
1169    async fn dupes_judge_dedicated_and_inherited_chains_advance_on_failure() {
1170        for dedicated in [true, false] {
1171            let dir = tempfile::tempdir().unwrap();
1172            let mut cfg = judge_config();
1173            let chain =
1174                crate::config::AgentChoice::Chain(vec!["failed".into(), "dedicated".into()]);
1175            if dedicated {
1176                cfg.roles.dupes_judge = Some(chain);
1177            } else {
1178                cfg.roles.chatter = Some(chain);
1179            }
1180            let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1181                .await
1182                .unwrap();
1183            assert_eq!(verdict.agent, "dedicated");
1184            assert_eq!(
1185                std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1186                "failed\ndedicated\n"
1187            );
1188        }
1189    }
1190
1191    #[cfg(unix)]
1192    #[tokio::test]
1193    async fn dupes_judge_unavailable_choice_does_not_inherit_chatter() {
1194        let dir = tempfile::tempdir().unwrap();
1195        let mut cfg = judge_config();
1196        cfg.roles.dupes_judge = Some("missing".into());
1197        let err = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1198            .await
1199            .unwrap_err();
1200        assert!(err.to_string().contains("[roles] dupes_judge"), "{err}");
1201        assert!(!dir.path().join("calls").exists());
1202    }
1203
1204    #[cfg(unix)]
1205    #[tokio::test]
1206    async fn dupes_judge_empty_choice_uses_default_instead_of_chatter() {
1207        let dir = tempfile::tempdir().unwrap();
1208        let mut cfg = judge_config();
1209        cfg.roles.chatter = Some("dedicated".into());
1210        cfg.roles.dupes_judge = Some(crate::config::AgentChoice::Chain(vec![]));
1211        let verdict = chain_judge(&cfg, dir.path(), "task".into(), vec![])
1212            .await
1213            .unwrap();
1214        assert_eq!(
1215            verdict.agent, "chat",
1216            "the first roster agent is the default"
1217        );
1218        assert_eq!(
1219            std::fs::read_to_string(dir.path().join("calls")).unwrap(),
1220            "chat\n"
1221        );
1222    }
1223
1224    struct Fx {
1225        _tmp: tempfile::TempDir,
1226        repo: PathBuf,
1227        runs: PathBuf,
1228        q: Queue,
1229    }
1230
1231    fn sh(cwd: &Path, args: &[&str]) -> String {
1232        let out = Command::new("git")
1233            .quiet()
1234            .args(["-c", "user.name=t", "-c", "user.email=t@t"])
1235            .args(args)
1236            .current_dir(cwd)
1237            .output()
1238            .unwrap();
1239        assert!(out.status.success(), "git {args:?}: {out:?}");
1240        String::from_utf8_lossy(&out.stdout).trim().to_owned()
1241    }
1242
1243    /// A repo with `main` and a branch `magi/aaaa/A` one commit ahead.
1244    /// Returns the fixture plus the (main, branch) tip SHAs.
1245    fn fx() -> (Fx, String, String) {
1246        let tmp = tempfile::tempdir().unwrap();
1247        let repo = tmp.path().join("repo");
1248        std::fs::create_dir_all(&repo).unwrap();
1249        sh(&repo, &["init", "-q", "-b", "main"]);
1250        std::fs::write(repo.join("a"), "1").unwrap();
1251        sh(&repo, &["add", "."]);
1252        sh(&repo, &["commit", "-q", "-m", "base"]);
1253        let base = sh(&repo, &["rev-parse", "HEAD"]);
1254        sh(&repo, &["checkout", "-q", "-b", "magi/aaaa/A"]);
1255        std::fs::write(repo.join("a"), "2").unwrap();
1256        sh(&repo, &["commit", "-q", "-am", "work"]);
1257        let tip = sh(&repo, &["rev-parse", "HEAD"]);
1258        sh(&repo, &["checkout", "-q", "main"]);
1259        let runs = tmp.path().join("runs");
1260        std::fs::create_dir_all(&runs).unwrap();
1261        let q = Queue::at(tmp.path().join("queue"));
1262        (
1263            Fx {
1264                _tmp: tmp,
1265                repo,
1266                runs,
1267                q,
1268            },
1269            base,
1270            tip,
1271        )
1272    }
1273
1274    fn write_run(f: &Fx, id: &str, status: &str, base: &str, pr: Option<(u64, &str)>) {
1275        let dir = f.runs.join(id);
1276        std::fs::create_dir_all(&dir).unwrap();
1277        let pr = pr.map(|(n, s)| {
1278            serde_json::json!({"url": format!("https://github.com/o/r/pull/{n}"), "number": n, "state": s})
1279        });
1280        let v = serde_json::json!({
1281            "schema": 999, "id": id, "repo": f.repo, "status": status,
1282            "base_commit": base, "candidates": [{"branch": "magi/aaaa/A"}], "pr": pr,
1283        });
1284        std::fs::write(dir.join("run.json"), v.to_string()).unwrap();
1285    }
1286
1287    fn file_task(f: &Fx, status: TaskStatus, runs: &[&str]) -> Task {
1288        let mut t = Task::new("t".into(), "x".into(), f.repo.clone(), Source::Human);
1289        t.status = status;
1290        t.runs = runs.iter().map(|s| (*s).to_owned()).collect();
1291        f.q.put(&mut t).unwrap();
1292        t
1293    }
1294
1295    const RID: &str = "20260901-100000-aaaa";
1296
1297    fn run(f: &Fx, text: &str, review: Option<&str>) -> Vec<Hit> {
1298        check_with(
1299            &f.q,
1300            &f.runs,
1301            &f.repo,
1302            text,
1303            review,
1304            None,
1305            &|_, _| None,
1306            &|_, _| None,
1307        )
1308    }
1309
1310    #[test]
1311    fn branch_matches_a_live_run_and_its_task() {
1312        let (f, base, _) = fx();
1313        write_run(&f, RID, "reviewing", &base, None);
1314        let t = file_task(&f, TaskStatus::Running, &[RID]);
1315        let hits = run(&f, "land magi/aaaa/A onto a fresh branch.", None);
1316        assert!(hits.iter().any(|h| h.owner == Owner::Task
1317            && h.id == t.id
1318            && h.signal == Signal::Branch
1319            && h.token == "magi/aaaa/A"));
1320        assert!(hits.iter().any(|h| h.owner == Owner::Run && h.id == RID));
1321        let msg = Duplicate::new(hits).to_string();
1322        assert!(
1323            msg.contains("--force") && msg.contains("magi/aaaa/A"),
1324            "{msg}"
1325        );
1326    }
1327
1328    #[test]
1329    fn branch_must_match_whole_word() {
1330        let (f, base, _) = fx();
1331        write_run(&f, RID, "reviewing", &base, None);
1332        assert!(run(&f, "see magi/aaaa/AB and magi/aaaa/A/x", None).is_empty());
1333    }
1334
1335    #[test]
1336    fn sha_on_the_branch_matches_but_one_in_base_does_not() {
1337        let (f, base, tip) = fx();
1338        write_run(&f, RID, "reviewing", &base, None);
1339        let hits = run(&f, &format!("land commit {} please", &tip[..8]), None);
1340        assert!(
1341            hits.iter().any(|h| h.signal == Signal::Sha && h.id == RID),
1342            "{hits:?}"
1343        );
1344        assert!(run(&f, &format!("see {}", &base[..9]), None).is_empty());
1345        // Hex that resolves to nothing is ignored.
1346        assert!(run(&f, "deadbeef and 1234567", None).is_empty());
1347    }
1348
1349    #[test]
1350    fn pr_number_matches_in_every_spelling() {
1351        let (f, base, _) = fx();
1352        write_run(&f, RID, "ready", &base, Some((48, "open")));
1353        for text in [
1354            "finish #48",
1355            "PR 48 is stale",
1356            "pr #48",
1357            "pull request 48",
1358            "https://github.com/o/r/pull/48",
1359        ] {
1360            let hits = run(&f, text, None);
1361            assert!(
1362                hits.iter().any(|h| h.signal == Signal::Pr),
1363                "{text}: {hits:?}"
1364            );
1365        }
1366        assert!(run(&f, "see #480 and PR 4 and issue48", None).is_empty());
1367    }
1368
1369    #[test]
1370    fn terminal_runs_and_done_tasks_do_not_match() {
1371        let (f, base, tip) = fx();
1372        write_run(&f, RID, "merged", &base, Some((48, "merged")));
1373        file_task(&f, TaskStatus::Done, &[RID]);
1374        let text = format!("magi/aaaa/A {} #48", &tip[..8]);
1375        assert!(run(&f, &text, None).is_empty());
1376    }
1377
1378    #[test]
1379    fn terminal_run_with_open_pr_or_open_task_still_claims() {
1380        // Changed deliberately: a terminal run's recorded-open PR now claims
1381        // only while the forge says open or cannot be read (`run` passes an
1382        // unreadable forge). A merged / closed answer or a released run no
1383        // longer claims; see the `stale_open_*` tests below.
1384        let (f, base, _) = fx();
1385        write_run(&f, RID, "ready", &base, Some((48, "open")));
1386        assert!(!run(&f, "magi/aaaa/A", None).is_empty());
1387        let (g, base, _) = fx();
1388        write_run(&g, RID, "ready", &base, None);
1389        assert!(run(&g, "magi/aaaa/A", None).is_empty());
1390        let t = file_task(&g, TaskStatus::Held, &[RID]);
1391        let hits = run(&g, "magi/aaaa/A", None);
1392        assert!(hits.iter().any(|h| h.id == t.id), "{hits:?}");
1393    }
1394
1395    #[test]
1396    fn review_only_matches_a_branch_a_live_task_owns() {
1397        let (f, base, _) = fx();
1398        write_run(&f, RID, "ready", &base, None);
1399        assert!(run(&f, "", Some("magi/aaaa/A")).is_empty());
1400        let mut t = file_task(&f, TaskStatus::Queued, &[]);
1401        t.review_branch = Some("magi/aaaa/A".into());
1402        f.q.put(&mut t).unwrap();
1403        let hits = run(&f, "", Some("magi/aaaa/A"));
1404        assert!(
1405            hits.iter()
1406                .any(|h| h.id == t.id && h.signal == Signal::Branch)
1407        );
1408    }
1409
1410    #[test]
1411    fn other_repository_and_edited_task_do_not_match() {
1412        let (f, base, _) = fx();
1413        write_run(&f, RID, "reviewing", &base, None);
1414        let t = file_task(&f, TaskStatus::Running, &[RID]);
1415        let other = f._tmp.path().join("other");
1416        std::fs::create_dir_all(&other).unwrap();
1417        sh(&other, &["init", "-q"]);
1418        assert!(
1419            check_with(
1420                &f.q,
1421                &f.runs,
1422                &other,
1423                "magi/aaaa/A",
1424                None,
1425                None,
1426                &|_, _| None,
1427                &|_, _| None,
1428            )
1429            .is_empty()
1430        );
1431        // Editing the task that owns the run never collides with itself.
1432        assert!(
1433            check_with(
1434                &f.q,
1435                &f.runs,
1436                &f.repo,
1437                "magi/aaaa/A",
1438                None,
1439                Some(&t.id),
1440                &|_, _| None,
1441                &|_, _| None,
1442            )
1443            .is_empty()
1444        );
1445    }
1446
1447    #[test]
1448    fn a_worktree_is_the_same_repository() {
1449        let (f, base, _) = fx();
1450        write_run(&f, RID, "reviewing", &base, None);
1451        let wt = f._tmp.path().join("wt");
1452        sh(
1453            &f.repo,
1454            &["worktree", "add", "-q", wt.to_str().unwrap(), "-b", "other"],
1455        );
1456        assert!(
1457            !check_with(
1458                &f.q,
1459                &f.runs,
1460                &wt,
1461                "magi/aaaa/A",
1462                None,
1463                None,
1464                &|_, _| None,
1465                &|_, _| None,
1466            )
1467            .is_empty()
1468        );
1469    }
1470
1471    #[test]
1472    fn an_open_pr_without_a_run_record_matches_through_the_forge() {
1473        let (f, _, _) = fx();
1474        let open = |_: &Path, n: u64| (n == 48).then(|| "https://example.test/pull/48".to_owned());
1475        let hit = |text: &str| {
1476            check_with(&f.q, &f.runs, &f.repo, text, None, None, &open, &|_, _| {
1477                None
1478            })
1479        };
1480        let hits = hit("finish PR #48");
1481        assert_eq!(hits.len(), 1, "{hits:?}");
1482        assert_eq!(hits[0].owner, Owner::Pr);
1483        assert!(hits[0].to_string().contains("#48"));
1484        // Closed / unknown / unnamed PRs never match.
1485        assert!(hit("finish PR #49").is_empty());
1486        assert!(hit("finish the work").is_empty());
1487    }
1488
1489    #[test]
1490    fn a_forge_hit_does_not_repeat_a_pr_a_run_already_explains() {
1491        let (f, base, _) = fx();
1492        write_run(&f, RID, "ready", &base, Some((48, "open")));
1493        let open = |_: &Path, _: u64| Some("u".to_owned());
1494        let hits = check_with(&f.q, &f.runs, &f.repo, "#48", None, None, &open, &|_, _| {
1495            None
1496        });
1497        assert!(hits.iter().all(|h| h.owner != Owner::Pr), "{hits:?}");
1498        assert!(!hits.is_empty());
1499    }
1500
1501    #[test]
1502    fn an_edited_tasks_own_open_pr_is_not_a_forge_hit() {
1503        let (f, base, _) = fx();
1504        write_run(&f, RID, "ready", &base, Some((48, "open")));
1505        let t = file_task(&f, TaskStatus::Running, &[RID]);
1506        let open = |_: &Path, _: u64| Some("u".to_owned());
1507        let hits = check_with(
1508            &f.q,
1509            &f.runs,
1510            &f.repo,
1511            "#48",
1512            None,
1513            Some(&t.id),
1514            &open,
1515            &|_, _| None,
1516        );
1517        assert!(hits.is_empty(), "{hits:?}");
1518    }
1519
1520    fn with_forge(f: &Fx, text: &str, state: Option<PrLifecycle>) -> Vec<Hit> {
1521        check_with(
1522            &f.q,
1523            &f.runs,
1524            &f.repo,
1525            text,
1526            None,
1527            None,
1528            &|_, _| None,
1529            &move |_, _| state,
1530        )
1531    }
1532
1533    fn release(f: &Fx, id: &str, to: &str) {
1534        let path = f.runs.join(id).join("run.json");
1535        let mut v: serde_json::Value =
1536            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1537        v["released_to"] = serde_json::json!(to);
1538        std::fs::write(path, v.to_string()).unwrap();
1539    }
1540
1541    #[test]
1542    fn a_task_public_head_and_pr_are_claimed_only_by_the_successor() {
1543        let (f, base, _) = fx();
1544        write_run(&f, RID, "blocked", &base, Some((48, "open")));
1545        let next = "20260901-000001-bbbb";
1546        write_run(&f, next, "landing", &base, Some((48, "open")));
1547        for id in [RID, next] {
1548            let path = f.runs.join(id).join("run.json");
1549            let mut value: serde_json::Value =
1550                serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1551            value["pr_head"] = serde_json::json!("magi/task-abcd-1234");
1552            if id == RID {
1553                value["pr_taken_over_by"] = serde_json::json!(next);
1554            }
1555            std::fs::write(path, value.to_string()).unwrap();
1556        }
1557        for text in ["update #48", "update magi/task-abcd-1234"] {
1558            let hits = with_forge(&f, text, Some(PrLifecycle::Open));
1559            assert!(!hits.is_empty());
1560            assert!(hits.iter().all(|h| h.id == next), "{hits:?}");
1561        }
1562        let task = file_task(&f, TaskStatus::Held, &[RID, next]);
1563        let hits = with_forge(&f, "update magi/task-abcd-1234", Some(PrLifecycle::Open));
1564        assert_eq!(hits.len(), 1, "{hits:?}");
1565        assert_eq!(hits[0].id, task.id);
1566    }
1567
1568    #[test]
1569    fn stale_open_pr_that_the_forge_says_is_merged_or_closed_does_not_claim() {
1570        for state in [PrLifecycle::Merged, PrLifecycle::Closed] {
1571            let (f, base, _) = fx();
1572            write_run(&f, RID, "superseded", &base, Some((48, "open")));
1573            assert!(with_forge(&f, "follow up on #48", Some(state)).is_empty());
1574            assert!(with_forge(&f, "magi/aaaa/A", Some(state)).is_empty());
1575            // Same through an owning, still-live task: the PR is not its claim.
1576            file_task(&f, TaskStatus::Held, &[RID]);
1577            let hits = with_forge(&f, "follow up on #48", Some(state));
1578            assert!(hits.iter().all(|h| h.signal != Signal::Pr), "{hits:?}");
1579        }
1580    }
1581
1582    #[test]
1583    fn stale_open_pr_with_an_unreadable_forge_still_claims() {
1584        let (f, base, _) = fx();
1585        write_run(&f, RID, "superseded", &base, Some((48, "open")));
1586        let hits = with_forge(&f, "follow up on #48", None);
1587        assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1588    }
1589
1590    #[test]
1591    fn a_genuinely_open_pr_still_claims() {
1592        let (f, base, _) = fx();
1593        write_run(&f, RID, "blocked", &base, Some((48, "open")));
1594        let hits = with_forge(&f, "follow up on #48", Some(PrLifecycle::Open));
1595        assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1596    }
1597
1598    #[test]
1599    fn a_released_run_defers_to_its_successor_without_asking_the_forge() {
1600        let (f, base, _) = fx();
1601        let next = "20260901-110000-bbbb";
1602        write_run(&f, RID, "superseded", &base, Some((48, "open")));
1603        write_run(&f, next, "merged", &base, Some((48, "merged")));
1604        release(&f, RID, next);
1605        let asked = std::cell::Cell::new(0);
1606        let hits = check_with(
1607            &f.q,
1608            &f.runs,
1609            &f.repo,
1610            "follow up on #48",
1611            None,
1612            None,
1613            &|_, _| None,
1614            &|_, _| {
1615                asked.set(asked.get() + 1);
1616                None
1617            },
1618        );
1619        assert!(hits.is_empty(), "{hits:?}");
1620        assert_eq!(asked.get(), 0);
1621        // A successor that cannot be read decides nothing: claim stands.
1622        std::fs::remove_dir_all(f.runs.join(next)).unwrap();
1623        let hits = with_forge(&f, "follow up on #48", None);
1624        assert!(hits.iter().any(|h| h.signal == Signal::Pr), "{hits:?}");
1625    }
1626
1627    #[test]
1628    fn forge_lookups_are_cached_and_stop_after_a_failure() {
1629        let (f, base, _) = fx();
1630        for (i, id) in ["20260901-100000-aaa1", "20260901-100000-aaa2"]
1631            .iter()
1632            .enumerate()
1633        {
1634            write_run(&f, id, "blocked", &base, Some((48 + i as u64, "open")));
1635        }
1636        let asked = std::cell::Cell::new(0);
1637        check_with(
1638            &f.q,
1639            &f.runs,
1640            &f.repo,
1641            "x",
1642            None,
1643            None,
1644            &|_, _| None,
1645            &|_, _| {
1646                asked.set(asked.get() + 1);
1647                None
1648            },
1649        );
1650        assert_eq!(
1651            asked.get(),
1652            1,
1653            "an unreadable forge is asked once, not per PR"
1654        );
1655    }
1656
1657    use std::sync::Arc;
1658    use std::sync::atomic::{AtomicUsize, Ordering};
1659
1660    fn verdict(duplicate: bool) -> Result<Judgement> {
1661        Ok(Judgement {
1662            duplicate,
1663            reason: "because".into(),
1664            agent: "j".into(),
1665        })
1666    }
1667
1668    /// Run `screen` over `text` with a judge answering `answer`; returns the
1669    /// outcome and how many times the judge was asked.
1670    fn judged(
1671        f: &Fx,
1672        text: &str,
1673        answer: impl Fn() -> Result<Judgement> + Send + Sync + 'static,
1674    ) -> (Result<Screened, Duplicate>, usize) {
1675        let calls = Arc::new(AtomicUsize::new(0));
1676        let seen = calls.clone();
1677        let judge = move |_: String, _: Vec<Hit>| -> JudgeFuture {
1678            seen.fetch_add(1, Ordering::SeqCst);
1679            let r = answer();
1680            Box::pin(async move { r })
1681        };
1682        let hits = run(f, text, None);
1683        let out = tokio::runtime::Builder::new_current_thread()
1684            .build()
1685            .unwrap()
1686            .block_on(screen(hits, text, None, &judge));
1687        (out, calls.load(Ordering::SeqCst))
1688    }
1689
1690    const NAMES: &str = "seen on magi/aaaa/A, which does not touch this file";
1691
1692    fn fx_with_run() -> Fx {
1693        let (f, base, _) = fx();
1694        write_run(&f, RID, "reviewing", &base, None);
1695        f
1696    }
1697
1698    #[test]
1699    fn a_no_ruling_lets_the_work_through() {
1700        let f = fx_with_run();
1701        let (out, calls) = judged(&f, NAMES, || verdict(false));
1702        assert!(matches!(out, Ok(Screened::Cleared(_))));
1703        assert_eq!(calls, 1);
1704    }
1705
1706    #[test]
1707    fn a_yes_ruling_refuses_and_says_why() {
1708        let f = fx_with_run();
1709        let (out, _) = judged(&f, NAMES, || verdict(true));
1710        let msg = out.unwrap_err().to_string();
1711        assert!(
1712            msg.contains("duplicate - because") && msg.contains("--force"),
1713            "{msg}"
1714        );
1715        assert!(msg.contains("magi/aaaa/A"), "{msg}");
1716    }
1717
1718    #[test]
1719    fn a_failing_judge_lets_it_through_unjudged() {
1720        let f = fx_with_run();
1721        let (out, calls) = judged(&f, NAMES, || Err(anyhow::anyhow!("quota")));
1722        match out {
1723            Ok(Screened::Unjudged(why)) => assert!(why.contains("quota"), "{why}"),
1724            other => panic!("{other:?}"),
1725        }
1726        assert_eq!(calls, 1);
1727    }
1728
1729    #[test]
1730    fn garbage_replies_do_not_parse() {
1731        assert!(parse_judgement("sure, go ahead").is_err());
1732        assert!(parse_judgement(r#"{"duplicate":"maybe","reason":"x"}"#).is_err());
1733        assert!(parse_judgement(r#"{"duplicate":false}"#).is_err());
1734        assert!(parse_judgement(r#"{"duplicate":false,"reason":"  "}"#).is_err());
1735        let two = "{\"duplicate\":false,\"reason\":\"c\"}\n{\"duplicate\":true}";
1736        assert!(parse_judgement(two).is_err());
1737        assert!(parse_judgement("ok {\"duplicate\":false,\"reason\":\"c\"}").is_err());
1738        let (d, why) = parse_judgement("{\"duplicate\":true,\"reason\":\"same\\nPR\"}").unwrap();
1739        assert_eq!((d, why.as_str()), (true, "same PR"));
1740    }
1741
1742    #[test]
1743    fn a_text_too_long_is_squeezed_and_still_judged() {
1744        let f = fx_with_run();
1745        let long = format!("{NAMES} {}", "x".repeat(prompt::DUPES_JUDGE_MAX_CHARS));
1746        let (out, calls) = judged(&f, &long, || verdict(false));
1747        assert!(matches!(out, Ok(Screened::Cleared(_))));
1748        assert_eq!(calls, 1);
1749    }
1750
1751    #[test]
1752    fn no_hit_never_asks_the_judge() {
1753        let (f, _, _) = fx();
1754        let (out, calls) = judged(&f, "nothing named here", || verdict(true));
1755        assert_eq!(out.unwrap(), Screened::Clean);
1756        assert_eq!(calls, 0);
1757    }
1758
1759    #[test]
1760    fn without_a_config_a_hit_passes_unjudged() {
1761        let f = fx_with_run();
1762        let hits = run(&f, NAMES, None);
1763        let out = tokio::runtime::Builder::new_current_thread()
1764            .build()
1765            .unwrap()
1766            .block_on(screen_with_config(hits, NAMES, None, &f.repo, None));
1767        assert!(matches!(out, Ok(Screened::Unjudged(_))));
1768    }
1769
1770    #[test]
1771    fn about_carries_title_and_whole_instruction() {
1772        assert_eq!(
1773            about_of("Retries", "# Task\nFix  auth\nretries"),
1774            "Retries: # Task Fix auth retries"
1775        );
1776        assert_eq!(about_of("", "x"), "x");
1777        let mid = format!("{} MIDDLE {}", "a ".repeat(2000), "b ".repeat(500));
1778        assert!(about_of("", &mid).contains("MIDDLE"));
1779        let long = format!("{} TAIL", "y ".repeat(5000));
1780        let a = about_of("", &long);
1781        assert!(a.ends_with("TAIL") && a.chars().count() < 6100, "{a}");
1782    }
1783}