Skip to main content

pma_core/
dispatch.rs

1//! Dispatch and review: a worktree per task, agent runs within the batch
2//! budget, verification by `pma` itself, and the review actions.
3
4use std::collections::VecDeque;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::Mutex;
8use std::sync::mpsc;
9use std::time::Duration;
10
11use crate::agent;
12use crate::class::Class;
13use crate::complexity::{self, Features};
14use crate::config::Config;
15use crate::rank::Quadrant;
16use crate::route::{Policy, Subject};
17use crate::scan;
18use crate::store::{Attempt, Result, Run, RunState, Store};
19use crate::todo::{self, Priority};
20use crate::worker::Worker;
21
22/// A task chosen for dispatch.
23#[derive(Debug, Clone)]
24pub struct Pick {
25    /// The workflow unit this dispatch serves, where one does. A route
26    /// matches on the node (W13) and a report groups by it.
27    pub workflow: Option<UnitRef>,
28    pub project: String,
29    pub repo: PathBuf,
30    /// The item's key, or a signal: `ci` or `deps`.
31    pub key: String,
32    pub text: String,
33    pub gh: Option<i64>,
34    /// The project's tier, recorded on the run because tiers change.
35    pub tier: Option<u8>,
36    /// Stated by a campaign, which has no item to read tags from.
37    pub class: Option<Class>,
38    /// Extra prompt lines a campaign carries.
39    pub details: Option<String>,
40    pub quadrant: Option<String>,
41    /// Start from the clone's `HEAD` rather than the fetched default branch.
42    /// A local run never reaches the remote.
43    pub local: bool,
44}
45
46/// Which workflow unit a dispatch serves.
47#[derive(Debug, Clone, PartialEq, Default)]
48pub struct UnitRef {
49    pub instance: i64,
50    /// The qualified node name.
51    pub node: String,
52    pub unit: String,
53    pub lap: i64,
54}
55
56/// An agent and a model named on the command line. Both override the
57/// configuration and an applied route: a flag is the most recent statement of
58/// intent there is, and it covers the whole invocation.
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct Overrides {
61    pub agent: Option<String>,
62    pub model: Option<String>,
63    /// A preset named on the command line. Its fields sit below the flags and
64    /// above an applied route, because naming one is a statement of intent
65    /// about this invocation and a route is policy about the work.
66    pub preset: Option<crate::store::Preset>,
67}
68
69/// What a target names after the colon.
70#[derive(Debug, Clone, PartialEq)]
71pub enum Target {
72    /// No colon: the project's open tasks, chosen from a list.
73    Project,
74    /// A TODO.md line from the last scan.
75    Line(i64),
76    /// `ci` or `deps`.
77    Signal(String),
78    /// Every open item under that heading.
79    Priority(Priority),
80    /// Every task the last scan placed in that quadrant.
81    Quadrant(Quadrant),
82}
83
84impl Target {
85    /// Whether it names at most one task, so a task it cannot take is an
86    /// error rather than something to pass over.
87    pub fn is_one(&self) -> bool {
88        matches!(self, Target::Line(_) | Target::Signal(_))
89    }
90}
91
92/// Splits `project:what`, or reads a bare project name.
93pub fn target(s: &str) -> Result<(&str, Target)> {
94    let Some((project, what)) = s.rsplit_once(':') else {
95        return match s.is_empty() {
96            true => Err("empty target; name a project such as `cynn`".into()),
97            false => Ok((s, Target::Project)),
98        };
99    };
100    if project.is_empty() {
101        return Err(format!("`{s}`: no project before the colon").into());
102    }
103    let target = match what {
104        "ci" | "deps" => Target::Signal(what.to_string()),
105        _ => {
106            if let Ok(line) = what.parse::<i64>() {
107                Target::Line(line)
108            } else if let Some(p) = Priority::parse(what) {
109                Target::Priority(p)
110            } else if let Some(q) = Quadrant::parse(what) {
111                Target::Quadrant(q)
112            } else {
113                return Err(format!(
114                    "`{s}`: after the colon expected a TODO.md line, ci, deps, \
115                     critical, high, medium, low, or q1 to q4"
116                )
117                .into());
118            }
119        }
120    };
121    Ok((project, target))
122}
123
124/// Whether a task key names a signal rather than a TODO.md item.
125pub fn is_signal(key: &str) -> bool {
126    matches!(key, "ci" | "deps")
127}
128
129/// A task with no line in any `TODO.md`: a signal, or a campaign applying one
130/// definition across repositories. There is nothing to check on origin before
131/// dispatch and nothing to tick when it is published.
132pub fn without_item(key: &str) -> bool {
133    is_signal(key) || key.starts_with("campaign:") || key.starts_with("workflow:")
134}
135
136/// Consumed attempts after which a task revision is not dispatched again.
137/// Two independent negative signals: an agent that cannot pass the check, or
138/// one that passed it and produced work a reviewer refused.
139pub const ATTEMPT_LIMIT: i64 = 2;
140
141/// A task's identity for the attempt counter. The normalised text, so a
142/// reworded specification starts fresh and a `ci` incident is identified by
143/// the workflows it names rather than by the word `ci`, which recurs.
144pub fn revision(text: &str) -> String {
145    todo::normal_text(text)
146}
147
148/// What a run's attempts count against. A workflow unit counts against its
149/// lineage, `workflow:<instance>:<root>`, so a retry and a lap draw from one
150/// budget (W23); anything else counts against its task text.
151pub fn attempt_key(run: &Run) -> String {
152    match run.task_key.starts_with("workflow:") {
153        true => run.task_key.clone(),
154        false => revision(&run.text),
155    }
156}
157
158/// Whether this attempt counts against the limit. A run that never reached
159/// the agent, or whose agent could not start or was killed at the timeout,
160/// says nothing about the task's suitability.
161fn consumes(run: &Run) -> bool {
162    run.state == RunState::Ready && run.verify_ok == Some(false)
163}
164
165/// Lines of failed CI log carried in a fix-CI prompt.
166const CI_LOG_LINES: usize = 200;
167
168/// Runs `git -C dir args`, returning trimmed stdout or an error naming the
169/// command and its stderr. With no filesystem monitor: one names a program,
170/// and these calls carry the user's credentials.
171pub fn git(dir: &Path, args: &[&str]) -> Result<String> {
172    let out = Command::new("git")
173        .arg("-C")
174        .arg(dir)
175        .args(["-c", "core.fsmonitor=false"])
176        .args(args)
177        .output()
178        .map_err(|e| format!("git: {e}"))?;
179    if !out.status.success() {
180        return Err(format!(
181            "git {}: {}",
182            args.join(" "),
183            String::from_utf8_lossy(&out.stderr).trim()
184        )
185        .into());
186    }
187    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
188}
189
190/// `git` in the run's worktree, once its `.git` is still the one `git
191/// worktree add` wrote and its hooks and settings are the ones taken at
192/// dispatch. An agent that replaced either would hand these calls, which
193/// carry the user's credentials, a program of its own to run.
194pub fn wt_git(run: &Run, args: &[&str]) -> Result<String> {
195    unchanged_git(run)?;
196    git(&run.worktree, args)
197}
198
199/// How a snapshot change reads in an error, which is also how a run that
200/// found one is recognised afterwards.
201const GIT_CHANGED: &str = "the repository's hooks or git settings changed since run #";
202
203/// Refuses a run whose repository gained or changed a hook, or a setting
204/// that runs a program or redirects a push, since dispatch. pma's git calls
205/// carry the user's credentials and environment, and none of that is in the
206/// diff the reviewer read. Also refuses a replaced `.git`, which is all a run
207/// from before the snapshot existed can be checked for.
208pub fn unchanged_git(run: &Run) -> Result<()> {
209    let Some(then) = &run.git_snapshot else {
210        return own_gitdir(run);
211    };
212    match changes(then, &git_snapshot(run)?) {
213        None => Ok(()),
214        Some(changes) => Err(format!(
215            "{GIT_CHANGED}{} was dispatched:\n{changes}\n\
216             pma runs no git in {} until they are restored, since its calls carry your \
217             credentials. If you made these changes, `pma review {} --reject \
218             --keep-git-changes`.",
219            run.id,
220            run.repo.display(),
221            run.id,
222        )
223        .into()),
224    }
225}
226
227/// The lines each snapshot has and the other lacks, or `None` when equal.
228fn changes(then: &str, now: &str) -> Option<String> {
229    if then == now {
230        return None;
231    }
232    let (then, now): (Vec<&str>, Vec<&str>) = (then.lines().collect(), now.lines().collect());
233    let lines: Vec<String> = then
234        .iter()
235        .filter(|l| !now.contains(l))
236        .map(|l| format!("  - {l}"))
237        .chain(
238            now.iter()
239                .filter(|l| !then.contains(l))
240                .map(|l| format!("  + {l}")),
241        )
242        .collect();
243    Some(lines.join("\n"))
244}
245
246/// Refuses to run git in `repo` while a run there that is not settled found
247/// its snapshot changed, until the clone matches that snapshot again. A
248/// setting an agent added stays in the clone after its run fails; the next
249/// fetch or `worktree add` would run it, and the next snapshot would take it
250/// as its baseline.
251pub fn clone_unchanged(store: &Store, repo: &Path) -> Result<()> {
252    clone_held(&store.runs()?, repo)
253}
254
255/// `clone_unchanged` over runs already read, for a caller checking many
256/// repositories.
257pub fn clone_held(runs: &[Run], repo: &Path) -> Result<()> {
258    let found: Vec<&Run> = runs
259        .iter()
260        .filter(|r| {
261            r.repo == repo
262                && !r.state.is_final()
263                && r.git_snapshot.is_some()
264                && r.error.as_deref().is_some_and(|e| e.contains(GIT_CHANGED))
265        })
266        .collect();
267    if found.is_empty() {
268        return Ok(());
269    }
270    let now = snapshot_in(repo, repo)?;
271    for run in found {
272        if let Some(changes) = changes(run.git_snapshot.as_deref().unwrap_or_default(), &now) {
273            return Err(format!(
274                "{GIT_CHANGED}{} was dispatched, and they are still in {}:\n{changes}\n\
275                 pma runs no git there until they are restored. If you made these changes, \
276                 `pma review {} --reject --keep-git-changes`.",
277                run.id,
278                repo.display(),
279                run.id,
280            )
281            .into());
282        }
283    }
284    Ok(())
285}
286
287/// Refuses a worktree whose `.git` is not a file naming this worktree's
288/// entry under the clone's `worktrees/`.
289pub fn own_gitdir(run: &Run) -> Result<()> {
290    let dot_git = run.worktree.join(".git");
291    let replaced = || -> Box<dyn std::error::Error> {
292        format!(
293            "{} is not the file `git worktree add` wrote; the run's worktree was \
294             tampered with, so pma runs no git in it. Reject the run.",
295            dot_git.display()
296        )
297        .into()
298    };
299    let is_file = std::fs::symlink_metadata(&dot_git).is_ok_and(|m| m.is_file());
300    let named = is_file
301        .then(|| std::fs::read_to_string(&dot_git).ok())
302        .flatten()
303        .and_then(|t| Some(PathBuf::from(t.strip_prefix("gitdir: ")?.trim_end())))
304        .and_then(|p| p.canonicalize().ok())
305        .ok_or_else(replaced)?;
306    let common = git(
307        &run.repo,
308        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
309    )?;
310    let expected = Path::new(&common)
311        .canonicalize()
312        .map_err(|e| format!("{common}: {e}"))?
313        .join("worktrees");
314    // The entry names its worktree back, so `.git` cannot borrow another's.
315    let back = std::fs::read_to_string(named.join("gitdir"))
316        .ok()
317        .and_then(|t| Path::new(t.trim_end()).canonicalize().ok());
318    if named.parent() != Some(expected.as_path()) || back != dot_git.canonicalize().ok() {
319        return Err(replaced());
320    }
321    Ok(())
322}
323
324/// What makes git run a program in the worktree, or push somewhere else,
325/// that the diff does not show: each hook by content and executable bit, and
326/// each repository setting that names a command or rewrites a URL, by a hash
327/// of its value, since a URL may carry a token. Publishing compares it with the one
328/// taken at dispatch. Hooks that run tracked files, such as husky's scripts or
329/// a `.pre-commit-config.yaml`, run what the reviewer approved in the diff.
330/// Global and system settings are the user's, so they are left out.
331pub fn git_snapshot(run: &Run) -> Result<String> {
332    own_gitdir(run)?;
333    snapshot_in(&run.repo, &run.worktree)
334}
335
336/// The snapshot as git reads it in `dir`, a worktree of `repo` or the clone
337/// itself.
338fn snapshot_in(repo: &Path, dir: &Path) -> Result<String> {
339    use std::os::unix::fs::PermissionsExt;
340
341    let mut lines = Vec::new();
342    let hooks = git(
343        dir,
344        &["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
345    )?;
346    let mut files: Vec<(String, PathBuf, bool)> = std::fs::read_dir(&hooks)
347        .into_iter()
348        .flatten()
349        .flatten()
350        .filter_map(|e| {
351            let name = e.file_name().into_string().ok()?;
352            let meta = std::fs::metadata(e.path()).ok()?;
353            (meta.is_file() && !name.ends_with(".sample"))
354                .then(|| (name, e.path(), meta.permissions().mode() & 0o111 != 0))
355        })
356        .collect();
357    files.sort();
358    if !files.is_empty() {
359        let mut args = vec!["hash-object", "--no-filters", "--"];
360        args.extend(files.iter().filter_map(|(_, p, _)| p.to_str()));
361        let hashes = git(repo, &args)?;
362        for ((name, _, executable), hash) in files.iter().zip(hashes.lines()) {
363            let x = if *executable {
364                "executable"
365            } else {
366                "not executable"
367            };
368            lines.push(format!("hook {name} {hash} {x}"));
369        }
370    }
371    // NUL-separated scope and entry, each entry `key\nvalue`.
372    let listing = git(dir, &["config", "--list", "--show-scope", "-z"])?;
373    let mut settings: Vec<(String, String)> = Vec::new();
374    let mut fields = listing.split('\0');
375    while let (Some(scope), Some(entry)) = (fields.next(), fields.next()) {
376        let (key, value) = entry.split_once('\n').unwrap_or((entry, ""));
377        if matches!(scope, "local" | "worktree") && runs_or_redirects(key) {
378            settings.push((key.to_string(), value.to_string()));
379        }
380    }
381    settings.sort();
382    for (key, value) in settings {
383        lines.push(format!("config {key} {}", hash_text(repo, &value)?));
384    }
385    Ok(lines.join("\n"))
386}
387
388/// A setting that names a program for git to run, or changes where a push
389/// goes. Keys arrive with section and name lowercased.
390fn runs_or_redirects(key: &str) -> bool {
391    let key = key.to_ascii_lowercase();
392    [
393        "core.hookspath",
394        "core.fsmonitor",
395        "core.sshcommand",
396        "core.editor",
397        "core.pager",
398        "core.askpass",
399        "core.gitproxy",
400        "diff.external",
401        "sequence.editor",
402        "remote.origin.url",
403        "remote.origin.pushurl",
404        "remote.pushdefault",
405    ]
406    .contains(&key.as_str())
407        || [
408            "filter.",
409            "credential.",
410            "url.",
411            "include.",
412            "includeif.",
413            "gpg.",
414        ]
415        .iter()
416        .any(|p| key.starts_with(p))
417        || [".textconv", ".command", ".driver"]
418            .iter()
419            .any(|s| key.ends_with(s))
420}
421
422/// Git's object id for `text`, so a value is compared without being stored.
423fn hash_text(dir: &Path, text: &str) -> Result<String> {
424    use std::io::Write;
425
426    let mut child = Command::new("git")
427        .arg("-C")
428        .arg(dir)
429        .args(["hash-object", "--no-filters", "--stdin"])
430        .stdin(std::process::Stdio::piped())
431        .stdout(std::process::Stdio::piped())
432        .stderr(std::process::Stdio::piped())
433        .spawn()
434        .map_err(|e| format!("git: {e}"))?;
435    child
436        .stdin
437        .take()
438        .ok_or("git hash-object: no stdin")?
439        .write_all(text.as_bytes())
440        .map_err(|e| format!("git hash-object: {e}"))?;
441    let out = child
442        .wait_with_output()
443        .map_err(|e| format!("git hash-object: {e}"))?;
444    if !out.status.success() {
445        return Err(format!(
446            "git hash-object: {}",
447            String::from_utf8_lossy(&out.stderr).trim()
448        )
449        .into());
450    }
451    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
452}
453
454/// Lowercase words of the task text joined by `-`, at most 40 bytes.
455pub fn slug(text: &str) -> String {
456    let mut out = String::new();
457    for word in text
458        .split(|c: char| !c.is_ascii_alphanumeric())
459        .filter(|w| !w.is_empty())
460    {
461        if out.len() + word.len() + 1 > 40 {
462            break;
463        }
464        if !out.is_empty() {
465            out.push('-');
466        }
467        out.push_str(&word.to_ascii_lowercase());
468    }
469    if out.is_empty() { "task".into() } else { out }
470}
471
472/// What `prepare` made of a pick.
473/// How a run refused before it started says so, in `Run::error`. A run that
474/// never reached the agent is not a run that failed: the caller leaves its
475/// work where it was rather than recording a result for it.
476pub const NOT_STARTED: &str = "not started: batch budget";
477
478#[derive(Debug)]
479pub enum Prepared {
480    Queued(Box<Run>),
481    /// The task is not dispatchable as the last scan described it. Other
482    /// tasks of the project may still be.
483    Refused(String),
484}
485
486/// Checks `pick` against the remote default branch, then creates its worktree
487/// and branch and records a queued run. An error concerns the whole project,
488/// such as a failed fetch.
489pub fn prepare(
490    store: &Store,
491    home: &Path,
492    cfg: &Config,
493    over: &Overrides,
494    pick: &Pick,
495) -> Result<Prepared> {
496    let repo = &pick.repo;
497    // Before the fetch, which reads `url.*` and `core.sshCommand`.
498    clone_unchanged(store, repo)?;
499    let (default_branch, base) = if pick.local {
500        (
501            git(repo, &["rev-parse", "--abbrev-ref", "HEAD"])?,
502            working_tree_commit(repo)?,
503        )
504    } else {
505        git(repo, &["fetch", "--quiet", "origin"])?;
506        let default_branch = scan::default_branch(repo).ok_or(scan::DEFAULT_BRANCH_UNKNOWN)?;
507        let base = git(
508            repo,
509            &[
510                "rev-parse",
511                &format!("refs/remotes/origin/{default_branch}"),
512            ],
513        )?;
514        (default_branch, base)
515    };
516    let refuse = |why: String| Ok(Prepared::Refused(format!("{}: {why}", pick.project)));
517
518    let (details, tags) = match pick.key.as_str() {
519        "ci" => {
520            let Some(scan::Ci::Failing(workflows)) = store.project(&pick.project)?.map(|p| p.ci)
521            else {
522                return refuse("CI was not failing at the last scan".into());
523            };
524            match failed_ci_logs(repo, &default_branch, &workflows)? {
525                Ok(logs) => (logs, Vec::new()),
526                Err(why) => return refuse(why),
527            }
528        }
529        "deps" => {
530            let detail = store
531                .project(&pick.project)?
532                .map(|p| p.deps_detail)
533                .unwrap_or_default();
534            (
535                format!(
536                    "Outdated dependencies at the last `pma scan --deps`:\n\n```\n{detail}\n```\n\n\
537                     Update them within the project's version constraints first. Change a \
538                     constraint only where the tests still pass, and list any dependency you \
539                     left behind, with the reason.\n"
540                ),
541                Vec::new(),
542            )
543        }
544        key if without_item(key) && !is_signal(key) => {
545            (pick.details.clone().unwrap_or_default(), Vec::new())
546        }
547        _ => {
548            let file = git(repo, &["show", &format!("{base}:TODO.md")]).ok();
549            match on_origin(file.as_deref(), &pick.key, &pick.text) {
550                OnOrigin::Open(description, tags) => (description, tags),
551                OnOrigin::Done => {
552                    return refuse(format!(
553                        "`{}` is already done on origin/{default_branch}; pull the clone, then `pma scan`",
554                        pick.text
555                    ));
556                }
557                OnOrigin::Absent => {
558                    return refuse(format!(
559                        "`{}` is not in TODO.md on origin/{default_branch}; commit and push it first",
560                        pick.text
561                    ));
562                }
563            }
564        }
565    };
566
567    // Before the worktree, so a task the user marked leaves nothing behind.
568    // A campaign states its class: it has no item to read tags from.
569    let class = pick.class.unwrap_or_else(|| Class::of(&pick.key, &tags));
570    if !class.dispatchable() {
571        return refuse(format!(
572            "`{}` is class {} and is not dispatched; remove `#manual` to change that",
573            pick.text,
574            class.name()
575        ));
576    }
577
578    let parent = home.join("worktrees").join(&pick.project);
579    let stem = slug(&pick.text);
580    let (slug, worktree) = (1..)
581        .map(|n| match n {
582            1 => stem.clone(),
583            n => format!("{stem}-{n}"),
584        })
585        .map(|s| (s.clone(), parent.join(&s)))
586        .find(|(s, path)| {
587            // A remote branch is left by an earlier pull request; pushing
588            // over it would be rejected.
589            !path.exists()
590                && [
591                    format!("refs/heads/pma/{s}"),
592                    format!("refs/remotes/origin/pma/{s}"),
593                ]
594                .iter()
595                .all(|r| git(repo, &["rev-parse", "--verify", "--quiet", r]).is_err())
596        })
597        .expect("an unused slug exists");
598    let branch = format!("pma/{slug}");
599    std::fs::create_dir_all(&parent).map_err(|e| format!("{}: {e}", parent.display()))?;
600    git(
601        repo,
602        &[
603            "worktree",
604            "add",
605            "--quiet",
606            "-b",
607            &branch,
608            path_arg(&worktree)?,
609            &base,
610        ],
611    )?;
612    // Anything short of a queued run releases the worktree and its branch
613    // here: no run row exists to release them later.
614    let prepared = queue(
615        store,
616        home,
617        cfg,
618        over,
619        pick,
620        &worktree,
621        &branch,
622        class,
623        default_branch,
624        base,
625        details,
626    );
627    if !matches!(prepared, Ok(Prepared::Queued(_))) {
628        let _ = remove_worktree(repo, &worktree, &branch);
629    }
630    prepared
631}
632
633/// A commit of the clone's working tree, untracked files included and ignored
634/// ones not, on top of `HEAD`. A local edit starts from it, so it sees the
635/// developer's uncommitted work and the edits applied before it, and its patch
636/// applies to the tree it was made from. Built in a temporary index, so the
637/// clone's index, refs and files are untouched; `HEAD` when nothing differs.
638fn working_tree_commit(repo: &Path) -> Result<String> {
639    let head = git(repo, &["rev-parse", "HEAD"])?;
640    let index = std::env::temp_dir().join(format!(
641        "pma-index-{}-{}",
642        std::process::id(),
643        crate::dates::now()
644    ));
645    let run = |args: &[&str]| -> Result<String> {
646        let out = Command::new("git")
647            .arg("-C")
648            .arg(repo)
649            .args(["-c", "core.fsmonitor=false"])
650            .args(args)
651            .env("GIT_INDEX_FILE", &index)
652            .env("GIT_AUTHOR_NAME", "pma")
653            .env("GIT_AUTHOR_EMAIL", "pma@localhost")
654            .env("GIT_COMMITTER_NAME", "pma")
655            .env("GIT_COMMITTER_EMAIL", "pma@localhost")
656            .output()
657            .map_err(|e| format!("git: {e}"))?;
658        if !out.status.success() {
659            return Err(format!(
660                "git {}: {}",
661                args.join(" "),
662                String::from_utf8_lossy(&out.stderr).trim()
663            )
664            .into());
665        }
666        Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
667    };
668    let built = (|| {
669        run(&["read-tree", &head])?;
670        run(&["add", "--all"])?;
671        let tree = run(&["write-tree"])?;
672        if tree == run(&["rev-parse", &format!("{head}^{{tree}}")])? {
673            return Ok(head.clone());
674        }
675        run(&["commit-tree", &tree, "-p", &head, "-m", "pma: working tree"])
676    })();
677    let _ = std::fs::remove_file(&index);
678    built
679}
680
681/// The part of `prepare` that runs once the worktree exists.
682#[allow(clippy::too_many_arguments)]
683fn queue(
684    store: &Store,
685    home: &Path,
686    cfg: &Config,
687    over: &Overrides,
688    pick: &Pick,
689    worktree: &Path,
690    branch: &str,
691    class: Class,
692    default_branch: String,
693    base: String,
694    details: String,
695) -> Result<Prepared> {
696    let repo = &pick.repo;
697    let refuse = |why: String| Ok(Prepared::Refused(format!("{}: {why}", pick.project)));
698    // Before base verify, which runs the project's code and could install a
699    // hook of its own.
700    let git_snapshot = git_snapshot(&Run {
701        repo: repo.clone(),
702        worktree: worktree.to_path_buf(),
703        ..Run::default()
704    })?;
705    let verify = verify_command(cfg, &pick.project, worktree);
706    // The worktree is a clean checkout of the base, so this is the base
707    // tree. One verify at the head alone cannot tell a regression from a
708    // repository that was already failing.
709    let (verify_base_ok, verify_base_seconds) = match &verify {
710        Some(v) => base_verify(store, home, cfg, &pick.project, &base, v, worktree)?,
711        None => (None, None),
712    };
713    let mut features = Features::of(&pick.text, &details);
714    features.repo_files = tracked_files(worktree);
715    features.verify_seconds = verify_base_seconds.filter(|_| verify.is_some());
716    (features.prior_decided, features.prior_accepted) = store.decided_runs(&pick.project)?;
717    let complexity = complexity::estimate(class, &features);
718
719    // A policy applies only once someone activates it; in shadow it is
720    // computed and recorded but the settings still decide.
721    // An untiered project is ranked at `default_tier`, so it is routed there.
722    let tier = cfg.tier_or_default(pick.tier);
723    let subject = Subject {
724        class,
725        complexity,
726        tier,
727        // A task dispatched on its own names no node, which is what a route
728        // stating no node condition serves; a workflow unit names its node.
729        node: pick.workflow.as_ref().map(|u| u.node.as_str()),
730        lap: pick.workflow.as_ref().map_or(0, |u| u.lap),
731    };
732    let active = store.active_route()?;
733    let mut routed = (None, None, None, None, class.scope());
734    if let Some(rev) = &active
735        && rev.shadow
736    {
737        // Recorded for `pma route replay`; it refuses, scopes and approves
738        // nothing.
739        let policy = rev.policy()?;
740        routed.0 = Some(rev.revision);
741        routed.1 = policy.route(&subject).map(|r| r.name.clone());
742    } else if let Some(rev) = &active {
743        let policy = rev.policy()?;
744        let Some(route) = policy.route(&subject) else {
745            return refuse(format!(
746                "`{}` matches no route in policy revision {}; add one or widen the last",
747                pick.text, rev.revision
748            ));
749        };
750        let scope = route.scope.clone().unwrap_or_else(|| class.scope());
751        routed = (
752            Some(rev.revision),
753            Some(route.name.clone()),
754            Some(route.approval),
755            Some((route.agent.clone(), route.model.clone())),
756            scope,
757        );
758    }
759    let (route_revision, route_name, approval, applied, scope) = routed;
760    let chosen = choose_worker(store, cfg, over, applied)?;
761    let (agent, model) = (chosen.agent.clone(), chosen.model.clone());
762
763    let mut run = Run {
764        id: 0,
765        project: pick.project.clone(),
766        task_key: pick.key.clone(),
767        text: pick.text.clone(),
768        gh: pick.gh,
769        quadrant: pick.quadrant.clone(),
770        agent,
771        repo: repo.clone(),
772        branch: branch.to_string(),
773        worktree: worktree.to_path_buf(),
774        default_branch,
775        base,
776        prompt: String::new(),
777        state: RunState::Queued,
778        feedback: None,
779        seconds: None,
780        cost_usd: None,
781        summary: None,
782        commits: None,
783        diffstat: None,
784        verify: verify.clone(),
785        verify_ok: None,
786        error: None,
787        outcome: None,
788        dispatched_at: None,
789        ready_at: None,
790        decided_at: None,
791        published_at: None,
792        review_seconds: None,
793        class: Some(class),
794        scope,
795        tier,
796        description: (!details.trim().is_empty()).then(|| details.clone()),
797        agent_budget: Some(cfg.agent_budget),
798        timeout_minutes: Some(cfg.timeout),
799        verify_base_ok,
800        verify_base_seconds,
801        changed_paths: None,
802        scope_error: None,
803        model,
804        complexity: Some(complexity),
805        features: Some(features),
806        estimator: Some(complexity::ESTIMATOR.into()),
807        route_revision,
808        route: route_name,
809        approval,
810        approved_tree: None,
811        approved_head: None,
812        approved_by: None,
813        // Set by the workflow pass; a task dispatched on its own has none.
814        workflow_instance: pick.workflow.as_ref().map(|u| u.instance),
815        node: pick.workflow.as_ref().map(|u| u.node.clone()),
816        unit: pick.workflow.as_ref().map(|u| u.unit.clone()),
817        lap: pick.workflow.as_ref().map_or(0, |u| u.lap),
818        preset: chosen.preset.clone(),
819        extra_args: chosen.args.clone(),
820        git_snapshot: Some(git_snapshot),
821        reviewed_tree: None,
822        infra: false,
823    };
824    run.prompt = prompt(&run, &details, verify.as_deref());
825    store.insert_run(&mut run)?;
826    Ok(Prepared::Queued(Box::new(run)))
827}
828
829pub fn path_arg(path: &Path) -> Result<&str> {
830    path.to_str()
831        .ok_or_else(|| format!("{} is not valid UTF-8", path.display()).into())
832}
833
834/// A task's item in the remote `TODO.md`.
835#[derive(Debug, PartialEq)]
836enum OnOrigin {
837    /// Open, with its description lines and its tags.
838    Open(String, Vec<String>),
839    Done,
840    /// Not in the file, or no file.
841    Absent,
842}
843
844fn on_origin(file: Option<&str>, key: &str, text: &str) -> OnOrigin {
845    let Some(file) = file else {
846        return OnOrigin::Absent;
847    };
848    let items = todo::parse(file).items;
849    let mut matching = items.iter().filter(|i| i.is_task(key, text));
850    match matching.clone().find(|i| !i.done) {
851        Some(item) => OnOrigin::Open(
852            item.description
853                .iter()
854                .map(|l| format!("{}\n", l.trim_start()))
855                .collect(),
856            item.tags.clone(),
857        ),
858        None if matching.next().is_some() => OnOrigin::Done,
859        None => OnOrigin::Absent,
860    }
861}
862
863/// The failed-job log of each workflow's latest decisive run, the run scan
864/// judged failing. The inner error refuses the task: a workflow passes now.
865fn failed_ci_logs(
866    repo: &Path,
867    branch: &str,
868    workflows: &[String],
869) -> Result<std::result::Result<String, String>> {
870    use crate::sync::gh;
871    let url = git(repo, &["remote", "get-url", "origin"])?;
872    let slug = scan::github_slug(&url).ok_or("origin is not on GitHub")?;
873    let mut logs = String::new();
874    for workflow in workflows {
875        let runs = gh(&[
876            "run",
877            "list",
878            "-R",
879            &slug,
880            "--branch",
881            branch,
882            "--workflow",
883            workflow,
884            "--limit",
885            "20",
886            "--json",
887            "databaseId,status,conclusion",
888        ])?;
889        let Some(id) = scan::latest_failed_run(&runs)? else {
890            return Ok(Err(format!(
891                "`{workflow}` no longer fails on {branch}; run `pma scan`"
892            )));
893        };
894        // A startup failure has no job log; the agent still gets the run id.
895        let log = gh(&["run", "view", &id.to_string(), "-R", &slug, "--log-failed"])
896            .map(|log| agent::tail(&log, CI_LOG_LINES))
897            .unwrap_or_else(|e| format!("(no failed-job log: {e})"));
898        logs.push_str(&format!(
899            "Workflow `{workflow}`, run {id}, last {CI_LOG_LINES} lines of `gh run view --log-failed`:\n\n```\n{log}\n```\n\n"
900        ));
901    }
902    Ok(Ok(logs))
903}
904
905/// Where an agent drafts changelog entries, relative to its worktree. pma
906/// moves the file out before any diff, so it is never part of the change.
907const CHANGELOG_DRAFT: &str = ".pma/changelog.md";
908
909/// Whether a run's agent is asked for changelog entries: its project keeps a
910/// `CHANGELOG.md`.
911pub fn wants_changelog(run: &Run) -> bool {
912    run.repo.join("CHANGELOG.md").is_file()
913}
914
915/// Where a run's drafted changelog entries are kept, outside its tree.
916pub fn changelog_draft(home: &Path, id: i64) -> PathBuf {
917    home.join("runs").join(id.to_string()).join("changelog.md")
918}
919
920fn prompt(run: &Run, details: &str, verify: Option<&str>) -> String {
921    let mut p = format!(
922        "You are working on the project `{}`, in a git worktree of it.\n\nTask: {}\n",
923        run.project, run.text
924    );
925    if !details.trim().is_empty() {
926        p.push('\n');
927        p.push_str(details);
928    }
929    p.push_str(
930        "\nRules:\n\
931         - Leave your changes uncommitted. Do not commit, push, or switch branches.\n\
932         - Do not edit TODO.md. pma marks the task done when the change is published.\n",
933    );
934    if wants_changelog(run) {
935        p.push_str(&format!(
936            "- Do not edit CHANGELOG.md. Write one entry per separable change to \
937             `{CHANGELOG_DRAFT}`: a bold one-line title, then what went wrong and what \
938             changed. pma keeps it out of your change and shows it to the reviewer.\n"
939        ));
940    }
941    if let Some(v) = verify {
942        p.push_str(&format!(
943            "- Run `{v}` to check your change. pma runs it again after you finish.\n"
944        ));
945    }
946    p.push_str("\nEnd with a short summary of what you changed and what is left undone.\n");
947    p
948}
949
950/// What a dispatch settled on: a worker, a model, the arguments that configure
951/// it, and the preset those came from.
952#[derive(Debug, Clone, PartialEq, Default)]
953pub struct Chosen {
954    pub agent: String,
955    pub model: Option<String>,
956    pub args: Vec<String>,
957    pub preset: Option<String>,
958}
959
960/// Which worker runs, at which model, with which configuration. Highest first:
961/// the `-a` and `-m` flags, a preset named on the command line, an applied
962/// route, then the default preset. A model named nowhere is a model this
963/// dispatch does not state: the worker is run without one and uses its own,
964/// which `runs.model` then records as null.
965pub fn choose_worker(
966    store: &Store,
967    cfg: &Config,
968    over: &Overrides,
969    applied: Option<(Option<String>, Option<String>)>,
970) -> Result<Chosen> {
971    let (route_agent, route_model) = applied.unwrap_or((None, None));
972    let default = match (&over.preset, &cfg.preset) {
973        (Some(_), _) => None,
974        (None, Some(name)) => Some(store.preset(name)?),
975        (None, None) => None,
976    };
977    let agent = over
978        .agent
979        .clone()
980        .or_else(|| over.preset.as_ref().map(|p| p.agent.clone()))
981        .or(route_agent)
982        .or_else(|| default.as_ref().map(|p| p.agent.clone()))
983        .unwrap_or_else(|| cfg.agent.clone());
984    // The default preset's model is for its own worker; a route that named
985    // another worker does not inherit it.
986    let model = over
987        .model
988        .clone()
989        .or_else(|| over.preset.as_ref().and_then(|p| p.model.clone()))
990        .or(route_model)
991        .or_else(|| {
992            default
993                .as_ref()
994                .filter(|p| p.agent == agent)
995                .and_then(|p| p.model.clone())
996        });
997    let named = over.preset.clone().or(default);
998    // Arguments come with the preset that named them or not at all: a route
999    // names a worker and a model, never a flag.
1000    let (args, preset) = match &named {
1001        Some(p) if p.agent == agent => (p.args.clone(), Some(p.name.clone())),
1002        _ => (Vec::new(), None),
1003    };
1004    Ok(Chosen {
1005        agent,
1006        model,
1007        args,
1008        preset,
1009    })
1010}
1011
1012/// The project's `verify` setting, else a command detected in the worktree.
1013/// `none` disables verification.
1014pub fn verify_command(cfg: &Config, project: &str, worktree: &Path) -> Option<String> {
1015    match cfg.project(project).verify {
1016        Some(v) if v == "none" => None,
1017        Some(v) => Some(v),
1018        None => agent::detect_verify(worktree),
1019    }
1020}
1021
1022/// How much sooner a worker that bounds itself is told to stop. A worker given
1023/// the same deadline as this process is killed by it first, and one that runs a
1024/// container then leaves the container for its own sweep to find.
1025pub const INNER_GRACE: Duration = Duration::from_secs(30);
1026
1027/// Runs queued runs, at most `max_parallel` at once, starting a run only while
1028/// the batch stays within `batch_budget`. `done` sees each run as it finishes.
1029pub fn execute(
1030    store: &Store,
1031    home: &Path,
1032    cfg: &Config,
1033    over: &Overrides,
1034    runs: Vec<Run>,
1035    mut done: impl FnMut(&Run),
1036) -> Result<Vec<Run>> {
1037    let workers = store.agents()?;
1038    // Escalation follows the policy only when it is applied. A shadow
1039    // revision is recorded and not acted on, here as everywhere. A model
1040    // named on the command line also holds: escalation picks a stronger
1041    // model, and the caller has just picked one.
1042    let policy = match store.active_route()? {
1043        Some(rev) if !rev.shadow && over.model.is_none() => Some(rev.policy()?),
1044        _ => None,
1045    };
1046    struct Queue {
1047        waiting: VecDeque<Run>,
1048        spent: f64,
1049        running: usize,
1050    }
1051    let queue = Mutex::new(Queue {
1052        waiting: runs.into(),
1053        spent: 0.0,
1054        running: 0,
1055    });
1056    let threads = (cfg.max_parallel as usize).max(1);
1057    // An attempt rides with the run it belongs to: only this thread has the
1058    // store, and a row appended after the run's update would be lost on a
1059    // save error.
1060    let (tx, rx) = mpsc::channel::<(Run, Vec<Attempt>)>();
1061    let mut finished = Vec::new();
1062    let mut save_error = None;
1063
1064    std::thread::scope(|s| {
1065        for _ in 0..threads {
1066            let tx = tx.clone();
1067            let queue = &queue;
1068            let workers = &workers;
1069            let policy = policy.as_ref();
1070            s.spawn(move || {
1071                loop {
1072                    let mut run = {
1073                        let mut q = queue.lock().unwrap();
1074                        let Some(mut run) = q.waiting.pop_front() else {
1075                            break;
1076                        };
1077                        // A route that may escalate reserves both attempts,
1078                        // so the second is not refused after the first spent
1079                        // the batch's room.
1080                        let tries = 1 + i64::from(escalation(policy, &run).is_some());
1081                        let committed = q.spent
1082                            + q.running as f64 * cfg.agent_budget
1083                            + tries as f64 * cfg.agent_budget;
1084                        if committed > cfg.batch_budget + 1e-9 {
1085                            run.state = RunState::Failed;
1086                            run.error =
1087                                Some(format!("{NOT_STARTED} ${} reached", cfg.batch_budget));
1088                            // Refused before the agent ran, so it consumes
1089                            // no attempt.
1090                            let _ = tx.send((run, Vec::new()));
1091                            continue;
1092                        }
1093                        q.running += 1;
1094                        run
1095                    };
1096                    run.state = RunState::Running;
1097                    let _ = tx.send((run.clone(), Vec::new()));
1098                    let mut made = vec![attempt(home, cfg, workers, &mut run, None)];
1099                    // One retry at a stronger model, only where the check
1100                    // itself refused the work.
1101                    if consumes(&run)
1102                        && let Some(model) = escalation(policy, &run)
1103                    {
1104                        run.model = Some(model);
1105                        made.push(attempt(home, cfg, workers, &mut run, Some(ESCALATION)));
1106                    }
1107                    {
1108                        let mut q = queue.lock().unwrap();
1109                        q.running -= 1;
1110                        // A worker that reports no cost is charged its whole
1111                        // budget per attempt, so it cannot run the batch past
1112                        // its limit unseen.
1113                        q.spent += run.cost_usd.unwrap_or(made.len() as f64 * cfg.agent_budget);
1114                    }
1115                    let _ = tx.send((run, made));
1116                }
1117            });
1118        }
1119        drop(tx);
1120        for (run, attempts) in rx {
1121            if let Err(e) = store.update_run(&run) {
1122                save_error.get_or_insert(e.to_string());
1123            }
1124            for a in &attempts {
1125                if let Err(e) = store.insert_attempt(a) {
1126                    save_error.get_or_insert(e.to_string());
1127                }
1128                // Escalation counts like any other attempt: each refused
1129                // check is one negative signal about the task.
1130                if a.verify_ok == Some(false)
1131                    && let Err(e) = store.consume_attempt(&run.project, &attempt_key(&run))
1132                {
1133                    save_error.get_or_insert(e.to_string());
1134                }
1135            }
1136            if run.state != RunState::Running {
1137                done(&run);
1138                finished.push(run);
1139            }
1140        }
1141    });
1142    match save_error {
1143        Some(e) => Err(e.into()),
1144        None => Ok(finished),
1145    }
1146}
1147
1148/// One agent run in the run's worktree, then verification. With `feedback`,
1149/// the reviewer's notes follow the original prompt.
1150///
1151/// The run accumulates cost and time across attempts, and keeps only the
1152/// latest summary and verify result. The returned `Attempt` holds this
1153/// attempt's own values; the caller appends it, because the worker threads
1154/// have no store.
1155#[must_use]
1156fn attempt(
1157    home: &Path,
1158    cfg: &Config,
1159    workers: &[Worker],
1160    run: &mut Run,
1161    feedback: Option<&str>,
1162) -> Attempt {
1163    let mut a = Attempt {
1164        id: 0,
1165        run_id: run.id,
1166        n: 0,
1167        agent: run.agent.clone(),
1168        model: run.model.clone(),
1169        prompt: String::new(),
1170        feedback: feedback.map(str::to_string),
1171        started_at: crate::dates::now(),
1172        seconds: None,
1173        cost_usd: None,
1174        summary: None,
1175        verify: None,
1176        verify_ok: None,
1177        error: None,
1178        outcome: None,
1179    };
1180    let Some(worker) = workers.iter().find(|w| w.name == run.agent) else {
1181        fail(
1182            run,
1183            &mut a,
1184            format!("unknown agent `{}`; see `pma agent`", run.agent),
1185        );
1186        return a;
1187    };
1188    let logs = home.join("runs").join(run.id.to_string());
1189    let agent_env = home.join("agent-env");
1190    if let Err(e) = std::fs::create_dir_all(&logs) {
1191        fail(run, &mut a, format!("{}: {e}", logs.display()));
1192        return a;
1193    }
1194    let n = (1..)
1195        .find(|n| !logs.join(format!("agent-{n}.log")).exists())
1196        .unwrap_or(1);
1197    let timeout = Duration::from_secs(cfg.timeout as u64 * 60);
1198
1199    a.prompt = match feedback {
1200        Some(f) => format!(
1201            "{}\nYour previous attempt is already in the working tree. The reviewer's feedback:\n\n{f}\n",
1202            run.prompt
1203        ),
1204        None => run.prompt.clone(),
1205    };
1206    a.verify = run.verify.clone();
1207    let mut cmd = worker.build(
1208        &a.prompt,
1209        &run.worktree,
1210        run.model.as_deref(),
1211        cfg.agent_budget,
1212        &run.extra_args,
1213        timeout.saturating_sub(INNER_GRACE),
1214    );
1215    worker.allow_verify(&mut cmd, run.verify.as_deref());
1216    cmd.current_dir(&run.worktree);
1217    if let Err(e) = agent::restrict(&mut cmd, &agent_env) {
1218        fail(run, &mut a, format!("{}: {e}", agent_env.display()));
1219        return a;
1220    }
1221    a.started_at = crate::dates::now();
1222    run.error = None;
1223    let log = logs.join(format!("agent-{n}.log"));
1224    let finished = match agent::run_limited(cmd, None, &log, timeout) {
1225        Ok(f) => f,
1226        Err(e) => {
1227            fail(run, &mut a, format!("{}: {e}", worker.command));
1228            return a;
1229        }
1230    };
1231    let report = worker.parse.report(
1232        &std::fs::read_to_string(&log).unwrap_or_default(),
1233        finished.success == Some(true),
1234    );
1235    a.seconds = Some(finished.seconds);
1236    run.seconds = Some(run.seconds.unwrap_or(0) + finished.seconds);
1237    // An agent that reports no cost leaves both null. Zero would read as free.
1238    if let Some(c) = report.cost_usd {
1239        a.cost_usd = Some(c);
1240        run.cost_usd = Some(run.cost_usd.unwrap_or(0.0) + c);
1241    }
1242    a.summary = Some(report.summary.clone());
1243    run.summary = Some(report.summary);
1244    // A regular file only: a symlink could copy any file pma can read into
1245    // the review.
1246    let drafted = run.worktree.join(CHANGELOG_DRAFT);
1247    if std::fs::symlink_metadata(&drafted).is_ok_and(|m| m.is_file())
1248        && std::fs::copy(&drafted, logs.join("changelog.md")).is_ok()
1249    {
1250        let _ = std::fs::remove_file(&drafted);
1251        let _ = std::fs::remove_dir(run.worktree.join(".pma"));
1252    }
1253    // Before any git call in the tree: what follows would run under a config
1254    // or hooks the agent wrote.
1255    if let Err(e) = unchanged_git(run) {
1256        fail(run, &mut a, e.to_string());
1257        return a;
1258    }
1259    run.commits = wt_git(
1260        run,
1261        &["rev-list", "--count", &format!("{}..HEAD", run.base)],
1262    )
1263    .ok()
1264    .and_then(|s| s.parse().ok());
1265    run.diffstat = diffstat(run);
1266    match changed_paths(run) {
1267        Ok(paths) => {
1268            run.changed_paths = Some(paths);
1269            run.scope_error = None;
1270        }
1271        Err(e) => {
1272            // Not an empty set: that would read as a run that changed
1273            // nothing, and pass every scope check.
1274            run.changed_paths = None;
1275            run.scope_error = Some(e.to_string());
1276        }
1277    }
1278
1279    let failure = match finished.success {
1280        None => Some(format!("timed out after {} minutes", cfg.timeout)),
1281        Some(_) if !report.ok => Some(format!("agent failed; log: {}", log.display())),
1282        _ => None,
1283    };
1284    if let Some(f) = failure {
1285        fail(run, &mut a, f);
1286        return a;
1287    }
1288
1289    run.verify_ok = None;
1290    if let Some(v) = &run.verify {
1291        let vlog = logs.join(format!("verify-{n}.log"));
1292        let sandbox = cfg.project(&run.project).sandbox;
1293        match verify_once(v, &run.worktree, sandbox, &agent_env, &vlog, timeout) {
1294            Ok((ok, seconds)) => {
1295                a.seconds = Some(a.seconds.unwrap_or(0) + seconds);
1296                run.seconds = Some(run.seconds.unwrap_or(0) + seconds);
1297                run.verify_ok = Some(ok == Some(true));
1298            }
1299            Err(e) => {
1300                run.verify_ok = Some(false);
1301                run.error = Some(format!("verify: {e}"));
1302                a.error = run.error.clone();
1303            }
1304        }
1305    }
1306    a.verify_ok = run.verify_ok;
1307    // After verify, so it is the tree `pma review` will show.
1308    run.reviewed_tree = tree(run).ok();
1309    run.enter(RunState::Ready);
1310    a.outcome = Some(RunState::Ready.name().into());
1311    a
1312}
1313
1314/// Files git tracks in the worktree, as a proxy for how far a change can
1315/// reach. Zero when it cannot be counted, which reads as a small repository
1316/// rather than refusing the dispatch.
1317fn tracked_files(worktree: &Path) -> i64 {
1318    git(worktree, &["ls-files"])
1319        .map(|out| out.lines().filter(|l| !l.is_empty()).count() as i64)
1320        .unwrap_or(0)
1321}
1322
1323/// Runs `command` in `worktree` under the agent's stripped environment, the
1324/// same way the head check runs it. `Ok(None)` means it was killed at the
1325/// timeout.
1326///
1327/// With `sandbox`, writes are confined to the worktree, the temp directory and
1328/// the toolchain caches, so a build file the agent edited cannot write
1329/// elsewhere on the host. Reads and the network stay open. Nothing under the
1330/// repository's `.git` is granted: a deleted object loses history nothing can
1331/// rebuild. A sandbox that cannot start is an error, not an unconfined run.
1332pub fn verify_once(
1333    command: &str,
1334    worktree: &Path,
1335    sandbox: bool,
1336    agent_env: &Path,
1337    log: &Path,
1338    timeout: Duration,
1339) -> std::io::Result<(Option<bool>, i64)> {
1340    let mut cmd = Command::new("sh");
1341    cmd.args(["-c", command]).current_dir(worktree);
1342    agent::restrict(&mut cmd, agent_env)?;
1343    let policy = match sandbox {
1344        true => Some(confine(worktree)?),
1345        false => None,
1346    };
1347    let f = agent::run_limited(cmd, policy.as_ref(), log, timeout)?;
1348    Ok((f.success, f.seconds))
1349}
1350
1351/// The verify sandbox for `worktree`, checked by running one confined command.
1352fn confine(worktree: &Path) -> std::io::Result<sanduk_sandbox::Policy> {
1353    let policy = sanduk_sandbox::Policy::new(worktree)?;
1354    policy.preflight().map_err(|e| {
1355        std::io::Error::other(format!(
1356            "the verify sandbox did not start ({e}); \
1357             `pma config projects.<name>.sandbox off` runs verify unconfined"
1358        ))
1359    })?;
1360    Ok(policy)
1361}
1362
1363/// `verify` at the base commit, measured once per project, base, command and
1364/// timeout. Every task of a project in one batch shares a base, so the batch
1365/// pays for one run. A failure to start is recorded as unknown rather than as
1366/// a failing base, which would read as a broken repository.
1367fn base_verify(
1368    store: &Store,
1369    home: &Path,
1370    cfg: &Config,
1371    project: &str,
1372    base: &str,
1373    command: &str,
1374    worktree: &Path,
1375) -> Result<(Option<bool>, Option<i64>)> {
1376    let sandbox = cfg.project(project).sandbox;
1377    if let Some((ok, seconds)) = store.verify_base(project, base, command, cfg.timeout, sandbox)? {
1378        return Ok((ok, Some(seconds)));
1379    }
1380    let (ok, seconds, _) = measure_base(store, home, cfg, project, base, command, worktree)?;
1381    Ok((ok, Some(seconds)))
1382}
1383
1384/// Runs `command` in `worktree`, a checkout of `base`, under the agent's
1385/// environment, and records the result in the base cache. Returns the result,
1386/// the seconds it took and the log.
1387fn measure_base(
1388    store: &Store,
1389    home: &Path,
1390    cfg: &Config,
1391    project: &str,
1392    base: &str,
1393    command: &str,
1394    worktree: &Path,
1395) -> Result<(Option<bool>, i64, PathBuf)> {
1396    let agent_env = home.join("agent-env");
1397    let logs = home.join("verify-base");
1398    std::fs::create_dir_all(&logs).map_err(|e| format!("{}: {e}", logs.display()))?;
1399    let log = logs.join(format!("{project}-{}.log", &base[..base.len().min(12)]));
1400    let timeout = Duration::from_secs(cfg.timeout as u64 * 60);
1401    let sandbox = cfg.project(project).sandbox;
1402    let (ok, seconds) = match verify_once(command, worktree, sandbox, &agent_env, &log, timeout) {
1403        Ok((success, seconds)) => (success, seconds),
1404        Err(_) => (None, 0),
1405    };
1406    store.set_verify_base(project, base, command, cfg.timeout, sandbox, ok, seconds)?;
1407    Ok((ok, seconds, log))
1408}
1409
1410/// What `pma verify` found for one project.
1411#[derive(Debug)]
1412pub struct Preflight {
1413    pub base: String,
1414    /// `None` when the project has no verify command.
1415    pub command: Option<String>,
1416    /// As in the base cache: `None` when the check timed out or could not
1417    /// start.
1418    pub ok: Option<bool>,
1419    pub seconds: i64,
1420    pub log: Option<PathBuf>,
1421}
1422
1423/// Runs the project's verify at the head of its remote default branch, in a
1424/// fresh worktree and under the agent's environment, as a dispatch's base
1425/// check does, and records the result in the base cache. A check that fails
1426/// for a reason of its own then shows before an agent is paid to find it.
1427/// Unlike dispatch, it measures again rather than reading the cache.
1428pub fn preflight(
1429    store: &Store,
1430    home: &Path,
1431    cfg: &Config,
1432    project: &str,
1433    repo: &Path,
1434) -> Result<Preflight> {
1435    clone_unchanged(store, repo)?;
1436    git(repo, &["fetch", "--quiet", "origin"])?;
1437    let default_branch = scan::default_branch(repo).ok_or(scan::DEFAULT_BRANCH_UNKNOWN)?;
1438    let base = git(
1439        repo,
1440        &[
1441            "rev-parse",
1442            &format!("refs/remotes/origin/{default_branch}"),
1443        ],
1444    )?;
1445    let tree = home.join("verify-trees").join(project);
1446    // Left by a preflight that was interrupted.
1447    remove_worktree(repo, &tree, "")?;
1448    if let Some(parent) = tree.parent() {
1449        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
1450    }
1451    git(
1452        repo,
1453        &[
1454            "worktree",
1455            "add",
1456            "--detach",
1457            "--quiet",
1458            path_arg(&tree)?,
1459            &base,
1460        ],
1461    )?;
1462    let measured = match verify_command(cfg, project, &tree) {
1463        None => Ok((None, None, 0, None)),
1464        Some(command) => measure_base(store, home, cfg, project, &base, &command, &tree)
1465            .map(|(ok, seconds, log)| (Some(command), ok, seconds, Some(log))),
1466    };
1467    let removed = remove_worktree(repo, &tree, "");
1468    let (command, ok, seconds, log) = measured?;
1469    removed?;
1470    Ok(Preflight {
1471        base,
1472        command,
1473        ok,
1474        seconds,
1475        log,
1476    })
1477}
1478
1479/// Added after the original prompt when a run is retried at a stronger
1480/// model. The worktree still holds the first attempt's work.
1481const ESCALATION: &str = "The project's check refused your previous attempt, which is still in \
1482                          the working tree. Reproduce the failure, then fix it.";
1483
1484/// The model a failed run retries at, or `None` when its route says nothing,
1485/// when the policy is not applied, or when it has already escalated.
1486fn escalation(policy: Option<&Policy>, run: &Run) -> Option<String> {
1487    let name = run.route.as_deref()?;
1488    let route = policy?.routes.iter().find(|r| r.name == name)?;
1489    let escalate = route.escalate.as_ref()?;
1490    // `attempts` is the number beyond the first, and only one is honoured.
1491    if escalate.attempts < 1 || run.model.as_deref() == Some(escalate.model.as_str()) {
1492        return None;
1493    }
1494    Some(escalate.model.clone())
1495}
1496
1497/// Fails the run and the attempt with one error, so neither records an
1498/// outcome the other contradicts.
1499fn fail(run: &mut Run, a: &mut Attempt, error: String) {
1500    run.state = RunState::Failed;
1501    run.error = Some(error.clone());
1502    a.error = Some(error);
1503    a.outcome = Some(RunState::Failed.name().into());
1504}
1505
1506/// The last line of `git diff --stat` against the base, untracked files
1507/// included.
1508fn diffstat(run: &Run) -> Option<String> {
1509    wt_git(run, &["add", "--all", "--intent-to-add"]).ok()?;
1510    let stat = wt_git(run, &["diff", "--stat", &run.base]).ok()?;
1511    Some(
1512        stat.lines()
1513            .last()
1514            .unwrap_or("no changes")
1515            .trim()
1516            .to_string(),
1517    )
1518}
1519
1520/// Every path the run changed against its base, untracked files included and
1521/// both sides of a rename listed. NUL-delimited, because a path may contain a
1522/// newline, and `--name-status` rather than `--name-only`, because a rename
1523/// names two paths and only the status says so.
1524fn changed_paths(run: &Run) -> Result<Vec<String>> {
1525    wt_git(run, &["add", "--all", "--intent-to-add"])?;
1526    let out = wt_git(
1527        run,
1528        &["diff", "--name-status", "-z", "--find-renames", &run.base],
1529    )?;
1530    let mut fields = out.split('\0').filter(|f| !f.is_empty());
1531    let mut paths = Vec::new();
1532    while let Some(status) = fields.next() {
1533        let wanted = match status.as_bytes().first() {
1534            // A rename or a copy names its source and its destination.
1535            Some(b'R' | b'C') => 2,
1536            Some(_) => 1,
1537            None => continue,
1538        };
1539        for _ in 0..wanted {
1540            match fields.next() {
1541                Some(p) => paths.push(p.to_string()),
1542                None => return Err(format!("`git diff` ended after status `{status}`").into()),
1543            }
1544        }
1545    }
1546    Ok(paths)
1547}
1548
1549/// `git diff` of the worktree against the run's base.
1550pub fn diff(run: &Run) -> Result<String> {
1551    wt_git(run, &["add", "--all", "--intent-to-add"])?;
1552    wt_git(run, &["diff", &run.base])
1553}
1554
1555/// The worktree's content as one object id: `git add --all`, which stages
1556/// without committing, then `git write-tree`. The same call when publishing
1557/// says whether anything changed since.
1558pub fn tree(run: &Run) -> Result<String> {
1559    wt_git(run, &["add", "--all"])?;
1560    wt_git(run, &["write-tree"])
1561}
1562
1563pub fn approve(store: &Store, run: &mut Run, by: &str) -> Result<()> {
1564    let read = run.state;
1565    // Approving an already approved run re-takes the evidence, which is how
1566    // a reviewer says the tree is fine after a publish refused a stale one.
1567    if !matches!(run.state, RunState::Ready | RunState::Approved) {
1568        return Err(format!(
1569            "run #{} is {}; only a ready or approved run can be approved",
1570            run.id,
1571            run.state.name()
1572        )
1573        .into());
1574    }
1575    if run.approval == Some(crate::route::Approval::Propose) {
1576        return Err(format!(
1577            "run #{} took route `{}`, which is `propose`: it produces a patch and a \
1578             summary, and nothing is published. Change the route to publish it.",
1579            run.id,
1580            run.route.as_deref().unwrap_or("-")
1581        )
1582        .into());
1583    }
1584    // What is approved is what the reviewer was last shown. Anything written
1585    // since, by an agent that outlived its run or by anyone, is read first.
1586    let now = tree(run)?;
1587    if run
1588        .reviewed_tree
1589        .as_ref()
1590        .is_some_and(|shown| shown != &now)
1591    {
1592        return Err(format!(
1593            "run #{} changed after it was last shown; read `pma review {}` again, \
1594             then approve it",
1595            run.id, run.id
1596        )
1597        .into());
1598    }
1599    run.approved_tree = Some(now);
1600    run.approved_head = wt_git(run, &["rev-parse", "HEAD"]).ok();
1601    run.approved_by = Some(by.to_string());
1602    run.enter(RunState::Approved);
1603    // No session lock is held: a publish may have run since `run` was read.
1604    store.update_run_from(run, read)
1605}
1606
1607/// `keep` accepts hooks or settings the run found changed as the user's own.
1608/// Without it, such a run is rejected only once the clone matches its
1609/// snapshot again, so rejecting does not quietly lift what `clone_unchanged`
1610/// holds.
1611pub fn reject(store: &Store, run: &mut Run, keep: bool, infra: bool) -> Result<()> {
1612    discard(store, run, keep)?;
1613    run.infra = infra;
1614    store.update_run(run)?;
1615    let attempts = store.attempts(Some(run.id))?;
1616    if infra {
1617        // The checks this run failed judged the infrastructure, not the task.
1618        let failed = attempts
1619            .iter()
1620            .filter(|a| a.verify_ok == Some(false))
1621            .count() as i64;
1622        store.refund_attempts(&run.project, &attempt_key(run), failed)?;
1623    } else if !attempts.is_empty() {
1624        // A reviewer who refuses the work has judged the task, whatever
1625        // verify made of it. A run that never started has no work to judge.
1626        store.consume_attempt(&run.project, &attempt_key(run))?;
1627    }
1628    Ok(())
1629}
1630
1631/// Removes a run's worktree and marks it rejected, consuming no attempt: what
1632/// `reject` does before a reviewer's judgement is counted. A local edit that
1633/// failed verify was counted by verify; one that did not apply was judged by
1634/// no one.
1635pub fn discard(store: &Store, run: &mut Run, keep: bool) -> Result<()> {
1636    let read = run.state;
1637    if run.state == RunState::PrOpen {
1638        return Err(format!(
1639            "run #{} has an open pull request; merge or close it: {}",
1640            run.id,
1641            run.outcome.as_deref().unwrap_or_default()
1642        )
1643        .into());
1644    }
1645    if run.state.is_final() || matches!(run.state, RunState::Queued | RunState::Running) {
1646        return Err(format!(
1647            "run #{} is {}; it cannot be rejected",
1648            run.id,
1649            run.state.name()
1650        )
1651        .into());
1652    }
1653    if !keep
1654        && let Some(then) = &run.git_snapshot
1655        && run
1656            .error
1657            .as_deref()
1658            .is_some_and(|e| e.contains(GIT_CHANGED))
1659        && let Some(changes) = changes(then, &snapshot_in(&run.repo, &run.repo)?)
1660    {
1661        return Err(format!(
1662            "run #{} found the hooks or git settings of {} changed, and they still are:\n\
1663             {changes}\nRestore them first, or pass --keep-git-changes if you made them.",
1664            run.id,
1665            run.repo.display()
1666        )
1667        .into());
1668    }
1669    // Written first, and only over the state that was read: a run another
1670    // front end approved meanwhile is refused with its worktree intact.
1671    run.enter(RunState::Rejected);
1672    store.update_run_from(run, read)?;
1673    remove_worktree(&run.repo, &run.worktree, &run.branch)
1674        .map_err(|e| format!("run #{} is rejected, but its worktree stays: {e}", run.id).into())
1675}
1676
1677/// Runs the agent again in the same worktree with the reviewer's feedback.
1678pub fn rework(
1679    store: &Store,
1680    home: &Path,
1681    cfg: &Config,
1682    run: &mut Run,
1683    feedback: &str,
1684) -> Result<()> {
1685    if !matches!(
1686        run.state,
1687        RunState::Ready | RunState::Failed | RunState::Approved
1688    ) {
1689        return Err(format!(
1690            "run #{} is {}; it cannot be reworked",
1691            run.id,
1692            run.state.name()
1693        )
1694        .into());
1695    }
1696    if !run.worktree.is_dir() {
1697        return Err(format!(
1698            "{} no longer exists; reject the run",
1699            run.worktree.display()
1700        )
1701        .into());
1702    }
1703    run.feedback = Some(feedback.to_string());
1704    // The approver read a tree that is about to change.
1705    run.approved_tree = None;
1706    run.approved_head = None;
1707    run.approved_by = None;
1708    run.state = RunState::Running;
1709    store.update_run(run)?;
1710    let a = attempt(home, cfg, &store.agents()?, run, Some(feedback));
1711    store.update_run(run)?;
1712    store.insert_attempt(&a)?;
1713    if consumes(run) {
1714        store.consume_attempt(&run.project, &attempt_key(run))?;
1715    }
1716    Ok(())
1717}
1718
1719/// Removes the worktree and its branch. Either may already be gone.
1720pub fn remove_worktree(repo: &Path, worktree: &Path, branch: &str) -> Result<()> {
1721    let gitfile = std::fs::symlink_metadata(worktree.join(".git")).is_ok_and(|m| m.is_file());
1722    if worktree.exists() && gitfile {
1723        git(
1724            repo,
1725            &["worktree", "remove", "--force", path_arg(worktree)?],
1726        )?;
1727    } else if worktree.exists() {
1728        // Git refuses a worktree whose `.git` is not its file. The directory
1729        // is pma's, so it goes directly, and `prune` drops the entry.
1730        std::fs::remove_dir_all(worktree).map_err(|e| format!("{}: {e}", worktree.display()))?;
1731    }
1732    let _ = git(repo, &["worktree", "prune"]);
1733    if git(
1734        repo,
1735        &[
1736            "rev-parse",
1737            "--verify",
1738            "--quiet",
1739            &format!("refs/heads/{branch}"),
1740        ],
1741    )
1742    .is_ok()
1743    {
1744        git(repo, &["branch", "-D", branch])?;
1745    }
1746    Ok(())
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751    use super::*;
1752
1753    #[test]
1754    fn a_target_names_a_project_a_line_a_signal_a_heading_or_a_quadrant() {
1755        let parse = |s| {
1756            let (project, what) = target(s).unwrap();
1757            (project.to_string(), what)
1758        };
1759        assert_eq!(parse("cynn"), ("cynn".into(), Target::Project));
1760        assert_eq!(parse("cynn:31"), ("cynn".into(), Target::Line(31)));
1761        assert_eq!(
1762            parse("cynn:ci"),
1763            ("cynn".into(), Target::Signal("ci".into()))
1764        );
1765        assert_eq!(
1766            parse("cynn:critical"),
1767            ("cynn".into(), Target::Priority(Priority::Critical))
1768        );
1769        assert_eq!(
1770            parse("cynn:q1"),
1771            ("cynn".into(), Target::Quadrant(Quadrant::Q1))
1772        );
1773        assert_eq!(
1774            parse("cynn:Q4"),
1775            ("cynn".into(), Target::Quadrant(Quadrant::Q4))
1776        );
1777    }
1778
1779    /// Only a target naming one task fails the batch when it cannot run.
1780    #[test]
1781    fn one_task_or_many() {
1782        assert!(Target::Line(3).is_one());
1783        assert!(Target::Signal("deps".into()).is_one());
1784        assert!(!Target::Priority(Priority::Low).is_one());
1785        assert!(!Target::Quadrant(Quadrant::Q2).is_one());
1786        assert!(!Target::Project.is_one());
1787    }
1788
1789    #[test]
1790    fn an_unreadable_target_says_what_is_accepted() {
1791        let err = |s| target(s).unwrap_err().to_string();
1792        assert!(err("cynn:soon").contains("expected a TODO.md line, ci, deps, critical"));
1793        assert!(err("cynn:q5").contains("expected a TODO.md line"));
1794        assert!(err(":31").contains("no project before the colon"));
1795        assert!(err("").contains("name a project"));
1796    }
1797
1798    #[test]
1799    fn slugs_are_short_ascii_words() {
1800        assert_eq!(
1801            slug("Fix the `Lexer` crash on Ctrl-C!"),
1802            "fix-the-lexer-crash-on-ctrl-c"
1803        );
1804        assert_eq!(slug("::"), "task");
1805        assert_eq!(
1806            slug("add a context manager for every network class and its trainer"),
1807            "add-a-context-manager-for-every-network"
1808        );
1809        assert_eq!(slug(&"x".repeat(60)), "task");
1810    }
1811
1812    #[test]
1813    fn prompts_carry_the_task_rules_and_verify() {
1814        let run = Run {
1815            id: 1,
1816            project: "cynn".into(),
1817            task_key: "k".into(),
1818            text: "Add evaluate()".into(),
1819            gh: None,
1820            quadrant: None,
1821            agent: "claude".into(),
1822            repo: PathBuf::new(),
1823            branch: String::new(),
1824            worktree: PathBuf::new(),
1825            default_branch: "main".into(),
1826            base: String::new(),
1827            prompt: String::new(),
1828            state: RunState::Queued,
1829            feedback: None,
1830            seconds: None,
1831            cost_usd: None,
1832            summary: None,
1833            commits: None,
1834            diffstat: None,
1835            verify: None,
1836            verify_ok: None,
1837            error: None,
1838            outcome: None,
1839            dispatched_at: None,
1840            ready_at: None,
1841            decided_at: None,
1842            published_at: None,
1843            review_seconds: None,
1844            class: Some(Class::Specified),
1845            scope: Vec::new(),
1846            tier: Some(1),
1847            description: None,
1848            agent_budget: None,
1849            timeout_minutes: None,
1850            verify_base_ok: None,
1851            verify_base_seconds: None,
1852            changed_paths: None,
1853            scope_error: None,
1854            model: None,
1855            complexity: None,
1856            features: None,
1857            estimator: None,
1858            route_revision: None,
1859            route: None,
1860            approval: None,
1861            approved_tree: None,
1862            approved_head: None,
1863            approved_by: None,
1864            workflow_instance: None,
1865            node: None,
1866            unit: None,
1867            lap: 0,
1868            preset: None,
1869            extra_args: Vec::new(),
1870            git_snapshot: None,
1871            reviewed_tree: None,
1872            infra: false,
1873        };
1874        let p = prompt(&run, "for all classes\n", Some("make test"));
1875        assert!(p.contains("`cynn`"));
1876        assert!(
1877            p.contains("Task: Add evaluate()\n\nfor all classes\n"),
1878            "{p}"
1879        );
1880        assert!(p.contains("Do not commit"));
1881        assert!(p.contains("Run `make test` to check your change."));
1882        assert!(!prompt(&run, "", None).contains("pma runs"));
1883
1884        let repo = std::env::temp_dir().join(format!("pma-changelog-{}", std::process::id()));
1885        std::fs::create_dir_all(&repo).unwrap();
1886        let run = Run {
1887            repo: repo.clone(),
1888            ..run
1889        };
1890        assert!(
1891            !prompt(&run, "", None).contains("CHANGELOG.md"),
1892            "the project keeps none"
1893        );
1894        std::fs::write(repo.join("CHANGELOG.md"), "# Changelog\n").unwrap();
1895        let p = prompt(&run, "", None);
1896        assert!(p.contains("Do not edit CHANGELOG.md"), "{p}");
1897        assert!(p.contains("`.pma/changelog.md`"), "{p}");
1898        let _ = std::fs::remove_dir_all(repo);
1899    }
1900
1901    /// Only a run that reached the agent and failed its check says anything
1902    /// about the task's suitability.
1903    #[test]
1904    fn an_attempt_counts_only_when_the_check_decided_against_it() {
1905        let ready = |verify_ok| Run {
1906            state: RunState::Ready,
1907            verify_ok,
1908            ..Run::blank()
1909        };
1910        assert!(consumes(&ready(Some(false))));
1911        assert!(!consumes(&ready(Some(true))), "an accepted check");
1912        assert!(!consumes(&ready(None)), "verify never ran");
1913        for error in [
1914            "not started: batch budget $5 reached",
1915            "timed out",
1916            "claude: no such file",
1917        ] {
1918            let run = Run {
1919                state: RunState::Failed,
1920                error: Some(error.into()),
1921                ..Run::blank()
1922            };
1923            assert!(!consumes(&run), "{error}");
1924        }
1925    }
1926
1927    /// A workflow unit's attempts count against its lineage, so every node and
1928    /// lap spending on it draws from one counter; a task's count against its
1929    /// text.
1930    #[test]
1931    fn a_workflow_unit_counts_attempts_against_its_lineage() {
1932        let unit = Run {
1933            task_key: "workflow:3:u1".into(),
1934            text: "Fix the parser".into(),
1935            ..Run::default()
1936        };
1937        assert_eq!(attempt_key(&unit), "workflow:3:u1");
1938        let task = Run {
1939            task_key: "fix the parser".into(),
1940            text: "Fix  the Parser".into(),
1941            ..Run::default()
1942        };
1943        assert_eq!(attempt_key(&task), "fix the parser");
1944    }
1945
1946    /// An item named like a signal, a campaign or a workflow unit is still an
1947    /// item: checked on origin before dispatch, and ticked when published.
1948    #[test]
1949    fn an_item_key_never_reads_as_another_kind_of_task() {
1950        let file = "# TODO\n\n## High\n\n- [ ] CI\n- [ ] deps\n- [ ] Workflow: migrate\n\
1951                    - [ ] campaign: x\n- [ ] item:ci\n- [ ] fix CI\n";
1952        let keys: Vec<String> = todo::parse(file).items.iter().map(|i| i.key()).collect();
1953        assert_eq!(
1954            keys,
1955            [
1956                "item:ci",
1957                "item:deps",
1958                "item:workflow: migrate",
1959                "item:campaign: x",
1960                "item:item:ci",
1961                "fix ci"
1962            ]
1963        );
1964        assert!(keys.iter().all(|k| !without_item(k)), "{keys:?}");
1965        assert_eq!(
1966            on_origin(Some(file), "item:ci", "CI"),
1967            OnOrigin::Open(String::new(), Vec::new())
1968        );
1969    }
1970
1971    #[test]
1972    fn a_revision_ignores_wording_that_does_not_change_the_task() {
1973        assert_eq!(revision("Fix  the PARSER"), revision("fix the parser"));
1974        assert_ne!(revision("fix CI: build"), revision("fix CI: build, test"));
1975    }
1976
1977    #[test]
1978    fn items_are_open_done_or_absent_on_origin() {
1979        let file = "# TODO\n\n## High\n\n- [ ] open one #manual gh:3\n  why\n- [x] finished\n";
1980        assert_eq!(
1981            on_origin(Some(file), "gh:3", "open one"),
1982            OnOrigin::Open("why\n".into(), vec!["manual".into()]),
1983            "the tags decide the class, so they travel with the item"
1984        );
1985        assert_eq!(
1986            on_origin(Some(file), "open one", "open one"),
1987            OnOrigin::Open("why\n".into(), vec!["manual".into()]),
1988            "a key that lost gh:N still matches by text"
1989        );
1990        assert_eq!(
1991            on_origin(Some(file), "finished", "finished"),
1992            OnOrigin::Done
1993        );
1994        assert_eq!(on_origin(Some(file), "other", "other"), OnOrigin::Absent);
1995        assert_eq!(on_origin(None, "open one", "open one"), OnOrigin::Absent);
1996    }
1997}