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