1use crate::backends::github::GitHubBackend;
2use crate::backends::{ExternalBackend, ExternalId};
3use crate::config;
4use crate::engine::cleanup::{
5 cleanup_orphaned_worktree, remove_worktree_and_branch, resolve_repo_root,
6};
7use crate::github::http::GhHttp;
8use crate::store::{Task, TaskStatus, TaskStore};
9use crate::tmux::TmuxManager;
10use std::io::Write;
11use std::path::Path;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15enum FixAction {
20 LinkPr { store_id: i64, pr_number: u64 },
22 ReopenIssue { store_id: i64 },
24 CloseIssue { store_id: i64 },
26 SyncLabels { store_id: i64 },
28 ResetToRouted { store_id: i64 },
30 CleanWorktree { store_id: i64 },
32 RemoveOrphanedWorktree { path: String },
34 MarkDoneFromMergedPr { store_id: i64 },
36 ResetDeadReview { store_id: i64 },
38 None,
40}
41
42struct Finding {
44 severity: Severity,
45 task_id: String,
46 message: String,
47 fix: FixAction,
48}
49
50#[derive(Clone, Copy)]
51enum Severity {
52 Error,
53 Warning,
54}
55
56impl Severity {
57 fn symbol(self) -> &'static str {
58 match self {
59 Self::Error => "ERR",
60 Self::Warning => "WARN",
61 }
62 }
63}
64
65pub async fn run(full: bool, fix: bool, dry_run: bool) -> anyhow::Result<()> {
70 let store = Arc::new(crate::cli::init_store().await?);
71 let gh = GhHttp::new()?;
72 let tmux = TmuxManager::new();
73
74 let repos: Vec<String> = if let Ok(repo) = config::get_current_repo() {
76 vec![repo]
77 } else {
78 let all = store.distinct_repos().await?;
79 if all.is_empty() {
80 println!("No tasks found in store.");
81 return Ok(());
82 }
83 all
84 };
85
86 let spinner_running = Arc::new(AtomicBool::new(true));
88 let spinner_flag = std::sync::Arc::clone(&spinner_running);
89 let spinner_task = tokio::spawn(async move {
90 let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
91 let mut i = 0usize;
92 while spinner_flag.load(Ordering::Relaxed) {
93 eprint!(
94 "\r{} Running orch service doctor…",
95 frames[i % frames.len()]
96 );
97 let _ = std::io::stderr().flush();
98 tokio::time::sleep(std::time::Duration::from_millis(80)).await;
99 i += 1;
100 }
101 eprint!("\r");
102 let _ = std::io::stderr().flush();
103 });
104
105 let result = run_checks(full, fix, dry_run, &repos, &store, &gh, &tmux).await;
106
107 spinner_running.store(false, Ordering::Relaxed);
108 let _ = spinner_task.await;
109
110 result
111}
112
113async fn run_checks(
114 full: bool,
115 fix: bool,
116 dry_run: bool,
117 repos: &[String],
118 store: &Arc<TaskStore>,
119 gh: &GhHttp,
120 tmux: &TmuxManager,
121) -> anyhow::Result<()> {
122 let mut all_findings: Vec<Finding> = Vec::new();
123
124 for repo in repos {
125 let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
126 let mut findings: Vec<Finding> = Vec::new();
127 if full {
128 let cutoff = chrono::Utc::now() - chrono::Duration::days(7);
132 let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%SZ").to_string();
133 let all_tasks = store.list_for_doctor(repo, &cutoff_str).await?;
134
135 let recent_done_tasks: Vec<_> = all_tasks
136 .iter()
137 .filter(|t| t.status == TaskStatus::Done)
138 .collect();
139 let active_tasks: Vec<_> = all_tasks
140 .iter()
141 .filter(|t| t.status != TaskStatus::Done)
142 .collect();
143 let in_progress_tasks: Vec<_> = all_tasks
144 .iter()
145 .filter(|t| t.status == TaskStatus::InProgress)
146 .collect();
147 let in_review_tasks: Vec<_> = all_tasks
148 .iter()
149 .filter(|t| t.status == TaskStatus::InReview)
150 .collect();
151
152 let pr_numbers_to_fetch: Vec<u64> = {
154 let mut nums: Vec<u64> = recent_done_tasks
155 .iter()
156 .filter(|t| !(t.origin == "internal" && t.external_id.is_none()))
157 .filter_map(|t| t.pr_number.and_then(|n| u64::try_from(n).ok()))
158 .chain(
159 active_tasks
160 .iter()
161 .filter_map(|t| t.pr_number.and_then(|n| u64::try_from(n).ok())),
162 )
163 .collect();
164 nums.sort_unstable();
165 nums.dedup();
166 nums
167 };
168 let pr_states = gh
169 .batch_get_pr_states(repo, &pr_numbers_to_fetch)
170 .await
171 .unwrap_or_default();
172
173 for task in &recent_done_tasks {
175 if task.origin == "internal" && task.external_id.is_none() {
176 continue;
177 }
178 if task.pr_number.is_none() && task.external_id.is_none() {
179 continue;
180 }
181 check_done_no_merged_pr(task, gh, &pr_states, &mut findings, repo).await;
182 }
183
184 for task in &recent_done_tasks {
186 check_dirty_worktree(task, &mut findings).await;
187 }
188
189 for task in &recent_done_tasks {
191 check_unpushed_commits(task, &mut findings).await;
192 }
193
194 let external_active: Vec<_> = active_tasks
197 .iter()
198 .copied()
199 .filter(|t| t.origin != "internal")
200 .collect();
201 let issue_numbers = collect_valid_external_issue_numbers(
202 external_active.iter().copied(),
203 repo,
204 "cli::doctor::run_checks(full)",
205 );
206 let issue_states = gh
207 .batch_get_issue_states(repo, &issue_numbers, "cli::doctor::run_checks(full)")
208 .await
209 .unwrap_or_default();
210 for task in &external_active {
211 check_issue_status_mismatch(task, &issue_states, &mut findings);
212 check_label_mismatch(task, &issue_states, &mut findings);
213 }
214
215 for task in &in_progress_tasks {
217 check_stale_in_progress(task, repo, tmux, &mut findings).await;
218 }
219
220 check_orphaned_worktrees(&all_tasks, &mut findings).await;
222
223 for task in &active_tasks {
225 check_pr_merged_not_done(task, &pr_states, &mut findings);
226 }
227
228 for task in &in_review_tasks {
230 check_dead_review_session(task, repo, tmux, &mut findings).await;
231 }
232 } else {
233 let active_tasks = store.list_active(repo).await?;
235
236 let open_prs: Vec<serde_json::Value> =
238 gh.list_open_pulls(repo).await.unwrap_or_default();
239
240 let mut pr_by_number: std::collections::HashMap<u64, serde_json::Value> =
242 std::collections::HashMap::new();
243 let mut pr_by_head: std::collections::HashMap<String, serde_json::Value> =
244 std::collections::HashMap::new();
245 for pr in &open_prs {
246 if let Some(n) = pr.get("number").and_then(serde_json::Value::as_u64) {
247 pr_by_number.insert(n, pr.clone());
248 }
249 if let Some(head) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
250 pr_by_head.insert(head.to_string(), pr.clone());
251 }
252 }
253
254 let external_active: Vec<_> = active_tasks
256 .iter()
257 .filter(|t| t.origin != "internal")
258 .collect();
259 for task in &active_tasks {
260 if task.status == TaskStatus::InProgress {
262 check_stale_in_progress(task, repo, tmux, &mut findings).await;
263 }
264
265 if task.status == TaskStatus::InReview {
267 check_dead_review_session(task, repo, tmux, &mut findings).await;
268 }
269
270 if let Some(pr_num) = task.pr_number.and_then(|n| u64::try_from(n).ok()) {
272 if let Ok(pr) = gh.get_pr(repo, pr_num).await {
273 if pr.merged.unwrap_or(false) {
274 findings.push(Finding {
275 severity: Severity::Error,
276 task_id: task_label(task),
277 message: format!(
278 "PR #{} is merged but task status is '{}'",
279 pr_num,
280 task.status.as_str()
281 ),
282 fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
283 });
284 }
285 } else {
286 }
288 } else if !task.branch.is_empty() {
289 if let Some(pr) = pr_by_head.get(&task.branch) {
291 if task.pr_number.is_none()
293 && let Some(n) = pr.get("number").and_then(serde_json::Value::as_u64)
294 {
295 findings.push(Finding {
296 severity: Severity::Warning,
297 task_id: task_label(task),
298 message: format!(
299 "branch '{}' has open PR #{} not linked in store",
300 task.branch, n
301 ),
302 fix: FixAction::LinkPr {
303 store_id: task.id,
304 pr_number: n,
305 },
306 });
307 }
308 }
309 }
310 }
311
312 let external_issue_numbers = collect_valid_external_issue_numbers(
314 external_active.iter().copied(),
315 repo,
316 "cli::doctor::run_checks(fast)",
317 );
318 let issue_states = gh
319 .batch_get_issue_states(
320 repo,
321 &external_issue_numbers,
322 "cli::doctor::run_checks(fast)",
323 )
324 .await
325 .unwrap_or_default();
326 for task in &external_active {
327 check_issue_status_mismatch(task, &issue_states, &mut findings);
328 check_label_mismatch(task, &issue_states, &mut findings);
329 }
330
331 for pr in &open_prs {
333 let pr_num = pr.get("number").and_then(serde_json::Value::as_u64);
334 let head = pr
335 .pointer("/head/ref")
336 .and_then(|v| v.as_str())
337 .map(std::string::ToString::to_string);
338 let mut owned = false;
339 if let Some(n) = pr_num {
340 for t in &active_tasks {
341 if let Some(pn) = t.pr_number
342 && u64::try_from(pn) == Ok(n)
343 {
344 owned = true;
345 break;
346 }
347 if !t.branch.is_empty() && Some(t.branch.clone()) == head {
348 owned = true;
349 break;
350 }
351 }
352 }
353 if !owned {
354 let num_str = pr_num.map_or_else(|| "?".to_string(), |n| n.to_string());
355 findings.push(Finding {
356 severity: Severity::Warning,
357 task_id: format!("pr:{num_str}"),
358 message: format!("open PR with no active task: #{num_str}"),
359 fix: FixAction::None,
360 });
361 }
362 }
363
364 let owners = store.list_all(repo).await?;
366 check_orphaned_worktrees(&owners, &mut findings).await;
367 }
368
369 if repos.len() > 1 && !findings.is_empty() {
370 println!("\n=== {repo} ===");
371 }
372
373 if (fix || dry_run) && !findings.is_empty() {
375 let errors = findings
376 .iter()
377 .filter(|f| matches!(f.severity, Severity::Error))
378 .count();
379 let warnings = findings.len() - errors;
380 for f in &findings {
381 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
382 }
383 println!(
384 "\nFound {} issue(s): {} error(s), {} warning(s)",
385 findings.len(),
386 errors,
387 warnings,
388 );
389 apply_fixes(&findings, store, repo, &backend, gh, dry_run).await?;
390 }
391
392 all_findings.extend(findings);
393 }
394
395 if all_findings.is_empty() {
396 println!("No issues found. All task state is consistent.");
397 return Ok(());
398 }
399
400 if !fix && !dry_run {
402 let errors = all_findings
403 .iter()
404 .filter(|f| matches!(f.severity, Severity::Error))
405 .count();
406 let warnings = all_findings.len() - errors;
407
408 for f in &all_findings {
409 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
410 }
411
412 println!(
413 "\nFound {} issue(s): {} error(s), {} warning(s)",
414 all_findings.len(),
415 errors,
416 warnings,
417 );
418
419 if errors > 0 {
420 println!("\nRun `orch service doctor --fix` to attempt automatic repairs.");
421 println!("Run `orch service doctor --dry-run` to preview what --fix would do.");
422 }
423 }
424
425 Ok(())
426}
427
428async fn check_done_no_merged_pr(
431 task: &Task,
432 gh: &GhHttp,
433 pr_states: &std::collections::HashMap<u64, (bool, String)>,
434 findings: &mut Vec<Finding>,
435 repo: &str,
436) {
437 let task_label = task_label(task);
438 let store_id = task.id;
439
440 match task.pr_number.and_then(|n| u64::try_from(n).ok()) {
441 Some(pr_num) => {
442 let (merged, state) = if let Some(s) = pr_states.get(&pr_num) {
444 s.clone()
445 } else {
446 match gh.get_pr(repo, pr_num).await {
447 Ok(pr) => (pr.merged.unwrap_or(false), pr.state),
448 Err(e) => {
449 findings.push(Finding {
450 severity: Severity::Warning,
451 task_id: task_label,
452 message: format!("done with PR #{pr_num} but failed to fetch PR: {e}"),
453 fix: FixAction::None,
454 });
455 return;
456 }
457 }
458 };
459 if !merged {
460 findings.push(Finding {
461 severity: Severity::Error,
462 task_id: task_label,
463 message: format!(
464 "done but PR #{pr_num} is {state} (not merged) — done is terminal, file a new task to finish the work"
465 ),
466 fix: FixAction::None,
467 });
468 }
469 }
470 None => {
471 if task.branch.is_empty() {
473 findings.push(Finding {
474 severity: Severity::Error,
475 task_id: task_label,
476 message: "done but no branch or PR recorded".to_string(),
477 fix: FixAction::None,
478 });
479 } else {
480 match gh.get_pr_number(repo, &task.branch).await {
481 Ok(Some(pr_num)) => {
482 let merged = gh
484 .get_pr(repo, pr_num)
485 .await
486 .is_ok_and(|pr| pr.merged == Some(true));
487 if merged {
488 findings.push(Finding {
490 severity: Severity::Warning,
491 task_id: task_label,
492 message: format!(
493 "done with no pr_number but branch has merged PR #{pr_num}"
494 ),
495 fix: FixAction::LinkPr {
496 store_id,
497 pr_number: pr_num,
498 },
499 });
500 } else {
501 findings.push(Finding {
503 severity: Severity::Error,
504 task_id: task_label,
505 message: format!(
506 "done with no pr_number but branch has unmerged PR #{pr_num} — done is terminal, file a new task to finish the work"
507 ),
508 fix: FixAction::None,
509 });
510 }
511 }
512 Ok(None) => {
513 findings.push(Finding {
514 severity: Severity::Error,
515 task_id: task_label,
516 message: describe_unrecovered_work(task).await,
517 fix: FixAction::None,
518 });
519 }
520 Err(_) => {
521 findings.push(Finding {
522 severity: Severity::Error,
523 task_id: task_label,
524 message: "done but no PR number recorded and branch lookup failed"
525 .to_string(),
526 fix: FixAction::None,
527 });
528 }
529 }
530 }
531 }
532 }
533}
534
535async fn describe_unrecovered_work(task: &Task) -> String {
540 const HEAD: &str = "done but no PR was ever created";
541 if task.worktree.is_empty() || task.worktree_cleaned {
542 return format!("{HEAD} and the worktree is gone — any work is lost");
543 }
544 let wt = Path::new(&task.worktree);
545 if !wt.exists() || !wt.join(".git").exists() {
546 return format!("{HEAD} and the worktree is gone — any work is lost");
547 }
548
549 let has_dirty = tokio::process::Command::new("git")
551 .args(["status", "--porcelain"])
552 .current_dir(wt)
553 .output()
554 .await
555 .is_ok_and(|o| !o.stdout.is_empty());
556
557 if has_dirty {
558 return format!("{HEAD}; uncommitted changes left in {}", task.worktree);
559 }
560
561 let has_unpushed = tokio::process::Command::new("git")
563 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
564 .current_dir(wt)
565 .output()
566 .await
567 .is_ok_and(|o| !o.stdout.is_empty());
568
569 if has_unpushed {
570 return format!("{HEAD}; unpushed commits left in {}", task.worktree);
571 }
572
573 let branch_on_remote = crate::engine::runner::git_ops::git()
575 .await
576 .args(["ls-remote", "--heads", "origin", &task.branch])
577 .current_dir(wt)
578 .output()
579 .await
580 .is_ok_and(|o| !o.stdout.is_empty());
581
582 if branch_on_remote {
583 return format!("{HEAD}; branch '{}' is pushed with no PR", task.branch);
584 }
585
586 HEAD.to_string()
587}
588
589async fn check_dirty_worktree(task: &Task, findings: &mut Vec<Finding>) {
591 if task.worktree.is_empty() || task.worktree_cleaned {
592 return;
593 }
594 let wt = Path::new(&task.worktree);
595 if !wt.exists() || !wt.join(".git").exists() {
596 return;
597 }
598 let output = tokio::process::Command::new("git")
599 .args(["status", "--porcelain"])
600 .current_dir(wt)
601 .output()
602 .await;
603 if let Ok(out) = output
604 && !out.stdout.is_empty()
605 {
606 findings.push(Finding {
607 severity: Severity::Error,
608 task_id: task_label(task),
609 message: format!(
610 "done but worktree has uncommitted changes: {} — done is terminal, file a new task to land them",
611 task.worktree
612 ),
613 fix: FixAction::None,
614 });
615 }
616}
617
618async fn check_unpushed_commits(task: &Task, findings: &mut Vec<Finding>) {
620 if task.worktree.is_empty() || task.worktree_cleaned || task.branch.is_empty() {
621 return;
622 }
623 let wt = Path::new(&task.worktree);
624 if !wt.exists() || !wt.join(".git").exists() {
625 return;
626 }
627 let output = tokio::process::Command::new("git")
628 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
629 .current_dir(wt)
630 .output()
631 .await;
632 if let Ok(out) = output
633 && !out.stdout.is_empty()
634 {
635 #[expect(
636 clippy::naive_bytecount,
637 reason = "a short git log does not need the bytecount crate"
638 )]
639 let count = out.stdout.iter().filter(|&&b| b == b'\n').count();
640 findings.push(Finding {
641 severity: Severity::Error,
642 task_id: task_label(task),
643 message: format!(
644 "done but {} unpushed commit(s) in worktree: {} — done is terminal, file a new task to land them",
645 count, task.worktree
646 ),
647 fix: FixAction::None,
648 });
649 }
650}
651
652fn check_issue_status_mismatch(
654 task: &Task,
655 issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
656 findings: &mut Vec<Finding>,
657) {
658 let ext_id = match &task.external_id {
659 Some(id) => id.clone(),
660 None => return,
661 };
662
663 let Some((state, _labels)) = issue_states.get(&ext_id) else {
664 return;
665 };
666
667 let issue_closed = state == "closed";
668 let task_done = task.status == TaskStatus::Done;
669 let task_blocked = task.status == TaskStatus::Blocked;
670
671 if issue_closed && !task_done && !task_blocked {
672 findings.push(Finding {
673 severity: Severity::Error,
674 task_id: task_label(task),
675 message: format!(
676 "GitHub issue is closed but SQLite status is '{}'",
677 task.status.as_str()
678 ),
679 fix: FixAction::ReopenIssue { store_id: task.id },
680 });
681 } else if !issue_closed && task_done {
682 findings.push(Finding {
683 severity: Severity::Warning,
684 task_id: task_label(task),
685 message: "SQLite status is 'done' but GitHub issue is still open".to_string(),
686 fix: FixAction::CloseIssue { store_id: task.id },
687 });
688 }
689}
690
691fn check_label_mismatch(
693 task: &Task,
694 issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
695 findings: &mut Vec<Finding>,
696) {
697 let ext_id = match &task.external_id {
698 Some(id) => id.clone(),
699 None => return,
700 };
701
702 let Some((_state, labels)) = issue_states.get(&ext_id) else {
703 return;
704 };
705
706 let expected_label = format!("status:{}", task.status.as_str());
707 let has_expected = labels.iter().any(|l| l == &expected_label);
708 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
709
710 if !has_expected {
711 let actual = if status_labels.is_empty() {
712 "none".to_string()
713 } else {
714 status_labels
715 .iter()
716 .map(|s| s.as_str())
717 .collect::<Vec<_>>()
718 .join(", ")
719 };
720 findings.push(Finding {
721 severity: Severity::Warning,
722 task_id: task_label(task),
723 message: format!(
724 "label mismatch: SQLite='{}' but GitHub labels=[{}]",
725 task.status.as_str(),
726 actual
727 ),
728 fix: FixAction::SyncLabels { store_id: task.id },
729 });
730 }
731}
732
733async fn check_stale_in_progress(
735 task: &Task,
736 repo: &str,
737 tmux: &TmuxManager,
738 findings: &mut Vec<Finding>,
739) {
740 let task_id_str = match &task.external_id {
741 Some(id) => id.clone(),
742 None => format!("internal:{}", task.id),
743 };
744 let session = tmux.session_name(repo, &task_id_str);
745 if !tmux.session_exists(&session).await {
746 findings.push(Finding {
747 severity: Severity::Error,
748 task_id: task_label(task),
749 message: format!("status is 'in_progress' but no tmux session '{session}' found"),
750 fix: FixAction::ResetToRouted { store_id: task.id },
751 });
752 }
753}
754
755async fn check_orphaned_worktrees(all_tasks: &[Task], findings: &mut Vec<Finding>) {
757 let Ok(worktrees_dir) = crate::home::worktrees_dir() else {
758 return;
759 };
760 check_orphaned_worktrees_in(&worktrees_dir, all_tasks, findings).await;
761}
762
763async fn check_orphaned_worktrees_in(
764 worktrees_dir: &Path,
765 all_tasks: &[Task],
766 findings: &mut Vec<Finding>,
767) {
768 let Ok(project_dirs) = std::fs::read_dir(worktrees_dir) else {
769 return;
770 };
771
772 let task_by_worktree: std::collections::HashMap<&str, &Task> = all_tasks
774 .iter()
775 .filter(|t| !t.worktree.is_empty())
776 .map(|t| (t.worktree.as_str(), t))
777 .collect();
778
779 for project_entry in project_dirs {
780 let project_entry = match project_entry {
781 Ok(e) => e,
782 Err(e) => {
783 tracing::warn!(err = %e, "error reading worktrees directory entry");
784 continue;
785 }
786 };
787 if !project_entry.file_type().is_ok_and(|t| t.is_dir()) {
788 continue;
789 }
790 let Ok(branch_dirs) = std::fs::read_dir(project_entry.path()) else {
791 continue;
792 };
793 for branch_entry in branch_dirs {
794 let branch_entry = match branch_entry {
795 Ok(e) => e,
796 Err(e) => {
797 tracing::warn!(err = %e, "error reading project directory entry");
798 continue;
799 }
800 };
801 if !branch_entry.file_type().is_ok_and(|t| t.is_dir()) {
802 continue;
803 }
804 let wt_path = branch_entry.path().to_string_lossy().to_string();
805
806 let owning_task = task_by_worktree.get(wt_path.as_str()).copied();
807
808 match owning_task {
809 Some(task) => {
810 if task.status == TaskStatus::Done && !task.worktree_cleaned {
811 let has_work = worktree_has_work(Path::new(&wt_path), &task.branch).await;
813 if has_work {
814 findings.push(Finding {
816 severity: Severity::Warning,
817 task_id: task_label(task),
818 message: format!("done but worktree has unsaved work: {wt_path}"),
819 fix: FixAction::None,
820 });
821 } else {
822 findings.push(Finding {
823 severity: Severity::Warning,
824 task_id: task_label(task),
825 message: format!("done but worktree not cleaned up: {wt_path}"),
826 fix: FixAction::CleanWorktree { store_id: task.id },
827 });
828 }
829 }
830 }
831 None => {
832 findings.push(Finding {
833 severity: Severity::Warning,
834 task_id: "?".to_string(),
835 message: format!("orphaned worktree (no task owns it): {wt_path}"),
836 fix: FixAction::RemoveOrphanedWorktree {
837 path: wt_path.clone(),
838 },
839 });
840 }
841 }
842 }
843 }
844}
845
846fn check_pr_merged_not_done(
848 task: &Task,
849 pr_states: &std::collections::HashMap<u64, (bool, String)>,
850 findings: &mut Vec<Finding>,
851) {
852 let Some(pr_num) = task.pr_number.and_then(|n| u64::try_from(n).ok()) else {
853 return;
854 };
855
856 if let Some((merged, _)) = pr_states.get(&pr_num)
857 && *merged
858 {
859 findings.push(Finding {
860 severity: Severity::Error,
861 task_id: task_label(task),
862 message: format!(
863 "PR #{} is merged but task status is '{}'",
864 pr_num,
865 task.status.as_str()
866 ),
867 fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
868 });
869 }
870}
871
872async fn check_dead_review_session(
874 task: &Task,
875 repo: &str,
876 tmux: &TmuxManager,
877 findings: &mut Vec<Finding>,
878) {
879 let task_id_str = match &task.external_id {
880 Some(id) => id.clone(),
881 None => format!("internal:{}", task.id),
882 };
883 let review_task_id = format!("{task_id_str}-review");
884 let session = tmux.session_name(repo, &review_task_id);
885 if !tmux.session_exists(&session).await {
886 findings.push(Finding {
887 severity: Severity::Error,
888 task_id: task_label(task),
889 message: format!("status is 'in_review' but no review session '{session}' found"),
890 fix: FixAction::ResetDeadReview { store_id: task.id },
891 });
892 }
893}
894
895async fn worktree_has_work(wt: &Path, branch: &str) -> bool {
897 if !wt.join(".git").exists() {
898 return false;
899 }
900 let has_dirty = tokio::process::Command::new("git")
901 .args(["status", "--porcelain"])
902 .current_dir(wt)
903 .output()
904 .await
905 .is_ok_and(|o| !o.stdout.is_empty());
906 if has_dirty {
907 return true;
908 }
909 if !branch.is_empty() {
910 let has_unpushed = tokio::process::Command::new("git")
911 .args(["log", &format!("origin/{branch}..HEAD"), "--oneline"])
912 .current_dir(wt)
913 .output()
914 .await
915 .is_ok_and(|o| !o.stdout.is_empty());
916 if has_unpushed {
917 return true;
918 }
919 }
920 false
921}
922
923async fn apply_fixes(
925 findings: &[Finding],
926 store: &Arc<TaskStore>,
927 repo: &str,
928 backend: &Arc<dyn ExternalBackend>,
929 gh: &GhHttp,
930 dry_run: bool,
931) -> anyhow::Result<()> {
932 let prefix = if dry_run { "[dry-run] " } else { "" };
933 let mut fixed = 0;
934 let mut skipped = 0;
935
936 for f in findings {
937 match &f.fix {
938 FixAction::None => {
939 skipped += 1;
940 }
941 FixAction::LinkPr {
942 store_id,
943 pr_number,
944 } => {
945 if dry_run {
946 println!(
947 " {}would link PR #{} to task #{}",
948 prefix, pr_number, f.task_id
949 );
950 fixed += 1;
951 continue;
952 }
953
954 let _ = store
955 .set_fields(*store_id, &[("pr_number", serde_json::json!(*pr_number))])
956 .await;
957 println!(" fixed #{}: linked PR #{}", f.task_id, pr_number);
958 fixed += 1;
959 }
960 FixAction::ReopenIssue { store_id } => {
961 let task = store.get(*store_id).await?;
962
963 if dry_run {
964 println!(
965 " {}would reopen GitHub issue + sync labels for #{}",
966 prefix, f.task_id
967 );
968 fixed += 1;
969 continue;
970 }
971
972 if let Some(ref ext_id) = task.external_id {
973 if let Err(e) = gh.reopen_issue(repo, ext_id).await {
974 eprintln!(" fix failed for #{}: reopen failed: {}", f.task_id, e);
975 skipped += 1;
976 continue;
977 }
978 let status = task_status_to_backend_status(task.status);
979 let _ = backend
980 .update_status(&ExternalId(ext_id.clone()), status)
981 .await;
982 println!(" fixed #{}: reopened issue + synced labels", f.task_id);
983 fixed += 1;
984 } else {
985 skipped += 1;
986 }
987 }
988 FixAction::CloseIssue { store_id } => {
989 let task = store.get(*store_id).await?;
990
991 if dry_run {
992 println!(" {}would close GitHub issue #{}", prefix, f.task_id);
993 fixed += 1;
994 continue;
995 }
996
997 if let Some(ref ext_id) = task.external_id {
998 let status = crate::backends::Status::Done;
999 if let Err(e) = backend
1000 .update_status(&ExternalId(ext_id.clone()), status)
1001 .await
1002 {
1003 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1004 skipped += 1;
1005 } else {
1006 println!(" fixed #{}: closed GitHub issue", f.task_id);
1007 fixed += 1;
1008 }
1009 } else {
1010 skipped += 1;
1011 }
1012 }
1013 FixAction::SyncLabels { store_id } => {
1014 let task = store.get(*store_id).await?;
1015
1016 if dry_run {
1017 println!(
1018 " {}would sync labels to status:{} for #{}",
1019 prefix,
1020 task.status.as_str(),
1021 f.task_id
1022 );
1023 fixed += 1;
1024 continue;
1025 }
1026
1027 if let Some(ref ext_id) = task.external_id {
1028 let status = task_status_to_backend_status(task.status);
1029 if let Err(e) = backend
1030 .update_status(&ExternalId(ext_id.clone()), status)
1031 .await
1032 {
1033 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1034 skipped += 1;
1035 } else {
1036 println!(" fixed #{}: synced labels", f.task_id);
1037 fixed += 1;
1038 }
1039 } else {
1040 skipped += 1;
1041 }
1042 }
1043 FixAction::ResetToRouted { store_id } => {
1044 if dry_run {
1045 println!(
1046 " {}would reset #{} to 'routed' (re-dispatch on next tick)",
1047 prefix, f.task_id
1048 );
1049 fixed += 1;
1050 continue;
1051 }
1052
1053 if let Err(e) = store.update_status(*store_id, TaskStatus::Routed).await {
1054 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1055 skipped += 1;
1056 continue;
1057 }
1058
1059 let task = store.get(*store_id).await?;
1061 if let Some(ref ext_id) = task.external_id {
1062 let _ = backend
1063 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Routed)
1064 .await;
1065 }
1066 println!(" fixed #{}: reset to routed", f.task_id);
1067 fixed += 1;
1068 }
1069 FixAction::CleanWorktree { store_id } => {
1070 let task = store.get(*store_id).await?;
1071
1072 if dry_run {
1073 println!(
1074 " {}would clean worktree + mark cleaned for #{}",
1075 prefix, f.task_id
1076 );
1077 fixed += 1;
1078 continue;
1079 }
1080
1081 let wt = Path::new(&task.worktree);
1082 if wt.exists() {
1083 match resolve_repo_root(repo).await {
1084 Ok(root) => {
1085 let branch = if task.branch.is_empty() {
1086 None
1087 } else {
1088 Some(task.branch.as_str())
1089 };
1090 let removed = remove_worktree_and_branch(
1091 &f.task_id,
1092 wt,
1093 branch,
1094 Path::new(&root),
1095 task.pr_number.is_some(), )
1097 .await;
1098 if removed {
1099 let _ = store.mark_cleaned(*store_id).await;
1100 println!(" fixed #{}: cleaned worktree", f.task_id);
1101 fixed += 1;
1102 } else {
1103 eprintln!(
1104 " fix failed for #{}: worktree removal failed",
1105 f.task_id
1106 );
1107 skipped += 1;
1108 }
1109 }
1110 Err(e) => {
1111 eprintln!(
1112 " fix failed for #{}: cannot resolve repo root: {}",
1113 f.task_id, e
1114 );
1115 skipped += 1;
1116 }
1117 }
1118 } else {
1119 let _ = store.mark_cleaned(*store_id).await;
1121 println!(
1122 " fixed #{}: marked worktree cleaned (already gone)",
1123 f.task_id
1124 );
1125 fixed += 1;
1126 }
1127 }
1128 FixAction::RemoveOrphanedWorktree { path } => {
1129 if dry_run {
1130 println!(" {prefix}would remove orphaned worktree: {path}");
1131 fixed += 1;
1132 continue;
1133 }
1134
1135 let removed = cleanup_orphaned_worktree(&f.task_id, Path::new(path)).await;
1136 if removed {
1137 println!(" fixed: removed orphaned worktree {path}");
1138 fixed += 1;
1139 } else {
1140 eprintln!(" fix failed: could not remove orphaned worktree {path}");
1141 skipped += 1;
1142 }
1143 }
1144 FixAction::MarkDoneFromMergedPr { store_id } => {
1145 let task = store.get(*store_id).await?;
1146
1147 if dry_run {
1148 println!(
1149 " {}would mark #{} done + close issue + clean worktree",
1150 prefix, f.task_id
1151 );
1152 fixed += 1;
1153 continue;
1154 }
1155
1156 if let Err(e) = store.update_status(*store_id, TaskStatus::Done).await {
1158 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1159 skipped += 1;
1160 continue;
1161 }
1162
1163 if let Some(ref ext_id) = task.external_id {
1165 let _ = backend
1166 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Done)
1167 .await;
1168 }
1169
1170 if !task.worktree.is_empty() && !task.worktree_cleaned {
1172 let wt = Path::new(&task.worktree);
1173 if wt.exists() {
1174 if let Ok(root) = resolve_repo_root(repo).await {
1175 let branch = if task.branch.is_empty() {
1176 None
1177 } else {
1178 Some(task.branch.as_str())
1179 };
1180 let removed = remove_worktree_and_branch(
1181 &f.task_id,
1182 wt,
1183 branch,
1184 Path::new(&root),
1185 true, )
1187 .await;
1188 if removed {
1189 let _ = store.mark_cleaned(*store_id).await;
1190 }
1191 }
1192 } else {
1193 let _ = store.mark_cleaned(*store_id).await;
1194 }
1195 }
1196
1197 println!(" fixed #{}: marked done + closed issue", f.task_id);
1198 fixed += 1;
1199 }
1200 FixAction::ResetDeadReview { store_id } => {
1201 if dry_run {
1202 println!(" {}would set #{} needs_review", prefix, f.task_id);
1203 fixed += 1;
1204 continue;
1205 }
1206
1207 if let Err(e) = store
1208 .update_status(*store_id, TaskStatus::NeedsReview)
1209 .await
1210 {
1211 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1212 skipped += 1;
1213 continue;
1214 }
1215
1216 let task = store.get(*store_id).await?;
1218 if let Some(ref ext_id) = task.external_id {
1219 let _ = backend
1220 .update_status(
1221 &ExternalId(ext_id.clone()),
1222 crate::backends::Status::NeedsReview,
1223 )
1224 .await;
1225 }
1226 println!(
1227 " fixed #{}: reset dead review session + set needs_review",
1228 f.task_id
1229 );
1230 fixed += 1;
1231 }
1232 }
1233 }
1234
1235 let action = if dry_run { "would fix" } else { "fixed" };
1236 println!("\n{action} {fixed}, skipped {skipped}.");
1237
1238 if !dry_run && skipped > 0 {
1239 println!("Reported issues with no automatic fix need a new issue or task.");
1240 }
1241
1242 Ok(())
1243}
1244
1245fn task_label(task: &Task) -> String {
1246 match &task.external_id {
1247 Some(id) => id.clone(),
1248 None => format!("internal:{}", task.id),
1249 }
1250}
1251
1252fn task_status_to_backend_status(status: TaskStatus) -> crate::backends::Status {
1253 match status {
1254 TaskStatus::New => crate::backends::Status::New,
1255 TaskStatus::Routed => crate::backends::Status::Routed,
1256 TaskStatus::InProgress => crate::backends::Status::InProgress,
1257 TaskStatus::Done => crate::backends::Status::Done,
1258 TaskStatus::Blocked => crate::backends::Status::Blocked,
1259 TaskStatus::InReview => crate::backends::Status::InReview,
1260 TaskStatus::Approved => crate::backends::Status::Approved,
1261 TaskStatus::NeedsReview => crate::backends::Status::NeedsReview,
1262 TaskStatus::Waiting => crate::backends::Status::Waiting,
1263 }
1264}
1265
1266fn collect_valid_external_issue_numbers<'a>(
1267 tasks: impl IntoIterator<Item = &'a Task>,
1268 repo: &str,
1269 caller_context: &str,
1270) -> Vec<u64> {
1271 let mut issue_numbers = Vec::new();
1272
1273 for task in tasks {
1274 let Some(external_id) = task.external_id.as_deref() else {
1275 continue;
1276 };
1277
1278 match external_id.parse::<u64>() {
1279 Ok(0) => {
1280 tracing::warn!(
1281 repo,
1282 caller_context,
1283 store_id = task.id,
1284 external_id,
1285 "dropping invalid external_id before batch issue-state query"
1286 );
1287 }
1288 Ok(issue_number) => issue_numbers.push(issue_number),
1289 Err(error) => {
1290 tracing::warn!(
1291 repo,
1292 caller_context,
1293 store_id = task.id,
1294 external_id,
1295 error = %error,
1296 "skipping non-numeric external_id in batch issue-state query"
1297 );
1298 }
1299 }
1300 }
1301
1302 issue_numbers.sort_unstable();
1303 issue_numbers.dedup();
1304 issue_numbers
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::collect_valid_external_issue_numbers;
1310 use crate::store::{Task, TaskStatus};
1311
1312 fn task(id: i64, external_id: Option<&str>, origin: &str) -> Task {
1313 Task {
1314 id,
1315 external_id: external_id.map(ToOwned::to_owned),
1316 repo: "owner/repo".to_string(),
1317 origin: origin.to_string(),
1318 title: String::new(),
1319 body: String::new(),
1320 status: TaskStatus::New,
1321 source: String::new(),
1322 source_id: String::new(),
1323 author: String::new(),
1324 url: String::new(),
1325 labels: Vec::new(),
1326 agent: None,
1327 model: None,
1328 complexity: String::new(),
1329 estimate: 0,
1330 route_reason: String::new(),
1331 agent_profile: String::new(),
1332 selected_skills: String::new(),
1333 route_attempts: 0,
1334 attempts: 0,
1335 branch: String::new(),
1336 worktree: String::new(),
1337 worktree_cleaned: false,
1338 owner_node: None,
1339 summary: String::new(),
1340 last_error: String::new(),
1341 parent_id: None,
1342 block_reason: None,
1343 pr_number: None,
1344 pr_review_context: String::new(),
1345 last_review_ts: String::new(),
1346 review_ts_map: String::new(),
1347 last_comment_review_ts: String::new(),
1348 merge_conflict_retries: 0,
1349 ci_merge_failures: 0,
1350 pr_create_failures: 0,
1351 push_failures: 0,
1352 network_retries: 0,
1353 review_agent_failures: 0,
1354 review_resets: 0,
1355 review_cycles: 0,
1356 review_invocations: 0,
1357 needs_review_refires: 0,
1358 input_tokens: 0,
1359 output_tokens: 0,
1360 input_cost_usd: 0.0,
1361 output_cost_usd: 0.0,
1362 total_cost_usd: 0.0,
1363 model_reroute_chain: String::new(),
1364 limit_reroute_chain: String::new(),
1365 memory: Vec::new(),
1366 delegations: Vec::new(),
1367 auto_unblock_count: 0,
1368 auto_unblock_last_at: String::new(),
1369 auto_unblock_last_reason: String::new(),
1370 ci_recovery_count: 0,
1371 no_code_reroutes: 0,
1372 no_code_last_agent: String::new(),
1373 created_at: String::new(),
1374 updated_at: String::new(),
1375 }
1376 }
1377
1378 #[tokio::test]
1379 async fn done_task_worktree_is_not_reported_as_orphaned() {
1380 let base = tempfile::tempdir().unwrap();
1381 let wt = base.path().join("proj").join("branch-1");
1382 std::fs::create_dir_all(&wt).unwrap();
1383
1384 let mut done = task(1, Some("1"), "github");
1385 done.status = TaskStatus::Done;
1386 done.worktree = wt.to_string_lossy().to_string();
1387
1388 let mut findings = Vec::new();
1389 super::check_orphaned_worktrees_in(base.path(), &[done], &mut findings).await;
1390 assert_eq!(findings.len(), 1);
1391 assert!(
1392 findings[0]
1393 .message
1394 .contains("done but worktree not cleaned up")
1395 );
1396 assert!(!findings[0].message.contains("orphaned"));
1397
1398 let mut findings = Vec::new();
1399 super::check_orphaned_worktrees_in(base.path(), &[], &mut findings).await;
1400 assert!(findings[0].message.contains("orphaned worktree"));
1401 }
1402
1403 #[test]
1404 fn collect_valid_external_issue_numbers_filters_zero_and_non_numeric_ids() {
1405 let tasks = [
1406 task(1, Some("42"), "github"),
1407 task(2, Some("0"), "github"),
1408 task(3, Some("not-a-number"), "github"),
1409 task(4, Some("42"), "github"),
1410 ];
1411
1412 let issue_numbers =
1413 collect_valid_external_issue_numbers(tasks.iter(), "owner/repo", "test");
1414
1415 assert_eq!(issue_numbers, vec![42]);
1416 }
1417}
1418
1419#[cfg(test)]
1420mod done_is_terminal_tests {
1421 use super::*;
1422 use crate::backends::test_helpers::NoopBackend;
1423 use crate::store::{NewTask, TaskStore};
1424
1425 fn git(dir: &Path, args: &[&str]) {
1426 let ok = std::process::Command::new("git")
1427 .args(args)
1428 .current_dir(dir)
1429 .env("GIT_AUTHOR_NAME", "t")
1430 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1431 .env("GIT_COMMITTER_NAME", "t")
1432 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1433 .output()
1434 .expect("git runs")
1435 .status
1436 .success();
1437 assert!(ok, "git {args:?} failed");
1438 }
1439
1440 #[tokio::test]
1443 async fn fix_leaves_done_task_status_alone() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let wt = dir.path();
1446 git(wt, &["init", "-b", "main"]);
1447 std::fs::write(wt.join("a.txt"), "a").unwrap();
1448 git(wt, &["add", "-A"]);
1449 git(wt, &["commit", "-m", "init"]);
1450 std::fs::write(wt.join("b.txt"), "b").unwrap();
1452
1453 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1454 let store_id = store
1455 .create(&NewTask {
1456 external_id: Some("58".to_string()),
1457 repo: "owner/repo".to_string(),
1458 origin: "github".to_string(),
1459 title: "Task".to_string(),
1460 body: String::new(),
1461 source: "cron".to_string(),
1462 source_id: "code-development".to_string(),
1463 author: "bot".to_string(),
1464 url: String::new(),
1465 labels: vec![],
1466 parent_id: None,
1467 })
1468 .await
1469 .unwrap();
1470 store
1471 .set_fields(
1472 store_id,
1473 &[
1474 ("worktree", serde_json::json!(wt.display().to_string())),
1475 ("branch", serde_json::json!("main")),
1476 ],
1477 )
1478 .await
1479 .unwrap();
1480 store
1481 .update_status(store_id, TaskStatus::Done)
1482 .await
1483 .unwrap();
1484 let task = store.get(store_id).await.unwrap();
1485
1486 let mut findings = Vec::new();
1487 check_dirty_worktree(&task, &mut findings).await;
1488 check_unpushed_commits(&task, &mut findings).await;
1489 assert!(
1490 !findings.is_empty(),
1491 "a done task with uncommitted work must still be reported"
1492 );
1493 for f in &findings {
1494 assert!(
1495 matches!(f.fix, FixAction::None),
1496 "done-task finding must carry no automatic fix: {}",
1497 f.message
1498 );
1499 }
1500
1501 let backend: Arc<dyn ExternalBackend> = Arc::new(NoopBackend);
1502 let gh = GhHttp::new().unwrap();
1503 apply_fixes(&findings, &store, "owner/repo", &backend, &gh, false)
1504 .await
1505 .unwrap();
1506
1507 assert_eq!(
1508 store.get(store_id).await.unwrap().status,
1509 TaskStatus::Done,
1510 "doctor --fix must not move a done task out of done"
1511 );
1512 }
1513}