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