1use 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#[derive(Debug, Clone)]
24pub struct Pick {
25 pub workflow: Option<UnitRef>,
28 pub project: String,
29 pub repo: PathBuf,
30 pub key: String,
32 pub text: String,
33 pub gh: Option<i64>,
34 pub tier: Option<u8>,
36 pub class: Option<Class>,
38 pub details: Option<String>,
40 pub quadrant: Option<String>,
41 pub local: bool,
44}
45
46#[derive(Debug, Clone, PartialEq, Default)]
48pub struct UnitRef {
49 pub instance: i64,
50 pub node: String,
52 pub unit: String,
53 pub lap: i64,
54}
55
56#[derive(Debug, Clone, Default, PartialEq)]
60pub struct Overrides {
61 pub agent: Option<String>,
62 pub model: Option<String>,
63 pub preset: Option<crate::store::Preset>,
67}
68
69#[derive(Debug, Clone, PartialEq)]
71pub enum Target {
72 Project,
74 Line(i64),
76 Signal(String),
78 Priority(Priority),
80 Quadrant(Quadrant),
82}
83
84impl Target {
85 pub fn is_one(&self) -> bool {
88 matches!(self, Target::Line(_) | Target::Signal(_))
89 }
90}
91
92pub 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
124pub fn is_signal(key: &str) -> bool {
126 matches!(key, "ci" | "deps")
127}
128
129pub fn without_item(key: &str) -> bool {
133 is_signal(key) || key.starts_with("campaign:") || key.starts_with("workflow:")
134}
135
136pub const ATTEMPT_LIMIT: i64 = 2;
140
141pub fn revision(text: &str) -> String {
145 todo::normal_text(text)
146}
147
148pub 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
158fn consumes(run: &Run) -> bool {
162 run.state == RunState::Ready && run.verify_ok == Some(false)
163}
164
165const CI_LOG_LINES: usize = 200;
167
168pub 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
190pub fn wt_git(run: &Run, args: &[&str]) -> Result<String> {
195 unchanged_git(run)?;
196 git(&run.worktree, args)
197}
198
199const GIT_CHANGED: &str = "the repository's hooks or git settings changed since run #";
202
203pub 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
227fn 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
246pub fn clone_unchanged(store: &Store, repo: &Path) -> Result<()> {
252 clone_held(&store.runs()?, repo)
253}
254
255pub 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
287pub 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 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
324pub fn git_snapshot(run: &Run) -> Result<String> {
332 own_gitdir(run)?;
333 snapshot_in(&run.repo, &run.worktree)
334}
335
336fn 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 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
388fn 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
422fn 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
454pub 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
472pub const NOT_STARTED: &str = "not started: batch budget";
477
478#[derive(Debug)]
479pub enum Prepared {
480 Queued(Box<Run>),
481 Refused(String),
484}
485
486pub 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 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 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 !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 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
633fn 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#[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 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 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 let tier = cfg.tier_or_default(pick.tier);
723 let subject = Subject {
724 class,
725 complexity,
726 tier,
727 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 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 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#[derive(Debug, PartialEq)]
836enum OnOrigin {
837 Open(String, Vec<String>),
839 Done,
840 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
863fn 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 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
905const CHANGELOG_DRAFT: &str = ".pma/changelog.md";
908
909pub fn wants_changelog(run: &Run) -> bool {
912 run.repo.join("CHANGELOG.md").is_file()
913}
914
915pub 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#[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
960pub 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 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 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
1012pub 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
1022pub const INNER_GRACE: Duration = Duration::from_secs(30);
1026
1027pub 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 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 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 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 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 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 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 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#[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 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 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 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 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 run.reviewed_tree = tree(run).ok();
1309 run.enter(RunState::Ready);
1310 a.outcome = Some(RunState::Ready.name().into());
1311 a
1312}
1313
1314fn 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
1323pub 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
1351fn 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
1363fn 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
1384fn 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#[derive(Debug)]
1412pub struct Preflight {
1413 pub base: String,
1414 pub command: Option<String>,
1416 pub ok: Option<bool>,
1419 pub seconds: i64,
1420 pub log: Option<PathBuf>,
1421}
1422
1423pub 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 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
1479const 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
1484fn 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 if escalate.attempts < 1 || run.model.as_deref() == Some(escalate.model.as_str()) {
1492 return None;
1493 }
1494 Some(escalate.model.clone())
1495}
1496
1497fn 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
1506fn 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
1520fn 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 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
1549pub fn diff(run: &Run) -> Result<String> {
1551 wt_git(run, &["add", "--all", "--intent-to-add"])?;
1552 wt_git(run, &["diff", &run.base])
1553}
1554
1555pub 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 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 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 store.update_run_from(run, read)
1605}
1606
1607pub 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 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 store.consume_attempt(&run.project, &attempt_key(run))?;
1627 }
1628 Ok(())
1629}
1630
1631pub 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 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
1677pub 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 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
1719pub 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 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 #[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 #[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 #[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 #[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}