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 check_orphaned_worktrees(&active_tasks, &mut findings).await;
372 }
373
374 if repos.len() > 1 && !findings.is_empty() {
375 println!("\n=== {} ===", repo);
376 }
377
378 if (fix || dry_run) && !findings.is_empty() {
380 let errors = findings
381 .iter()
382 .filter(|f| matches!(f.severity, Severity::Error))
383 .count();
384 let warnings = findings.len() - errors;
385 for f in &findings {
386 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
387 }
388 println!(
389 "\nFound {} issue(s): {} error(s), {} warning(s)",
390 findings.len(),
391 errors,
392 warnings,
393 );
394 apply_fixes(&findings, store, repo, &backend, gh, dry_run).await?;
395 }
396
397 all_findings.extend(findings);
398 }
399
400 if all_findings.is_empty() {
401 println!("No issues found. All task state is consistent.");
402 return Ok(());
403 }
404
405 if !fix && !dry_run {
407 let errors = all_findings
408 .iter()
409 .filter(|f| matches!(f.severity, Severity::Error))
410 .count();
411 let warnings = all_findings.len() - errors;
412
413 for f in &all_findings {
414 println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
415 }
416
417 println!(
418 "\nFound {} issue(s): {} error(s), {} warning(s)",
419 all_findings.len(),
420 errors,
421 warnings,
422 );
423
424 if errors > 0 {
425 println!("\nRun `orch service doctor --fix` to attempt automatic repairs.");
426 println!("Run `orch service doctor --dry-run` to preview what --fix would do.");
427 }
428 }
429
430 Ok(())
431}
432
433async fn check_done_no_merged_pr(
436 task: &Task,
437 gh: &GhHttp,
438 pr_states: &std::collections::HashMap<u64, (bool, String)>,
439 findings: &mut Vec<Finding>,
440 repo: &str,
441) {
442 let task_label = task_label(task);
443 let store_id = task.id;
444
445 match task.pr_number {
446 Some(pr_num) => {
447 let (merged, state) = if let Some(s) = pr_states.get(&(pr_num as u64)) {
449 s.clone()
450 } else {
451 match gh.get_pr(repo, pr_num as u64).await {
452 Ok(pr) => (pr.merged.unwrap_or(false), pr.state),
453 Err(e) => {
454 findings.push(Finding {
455 severity: Severity::Warning,
456 task_id: task_label,
457 message: format!(
458 "done with PR #{} but failed to fetch PR: {}",
459 pr_num, e
460 ),
461 fix: FixAction::None,
462 });
463 return;
464 }
465 }
466 };
467 if !merged {
468 findings.push(Finding {
469 severity: Severity::Error,
470 task_id: task_label,
471 message: format!(
472 "done but PR #{} is {} (not merged) — done is terminal, file a new task to finish the work",
473 pr_num, state
474 ),
475 fix: FixAction::None,
476 });
477 }
478 }
479 None => {
480 if !task.branch.is_empty() {
482 match gh.get_pr_number(repo, &task.branch).await {
483 Ok(Some(pr_num)) => {
484 let merged = gh
486 .get_pr(repo, pr_num)
487 .await
488 .map(|pr| pr.merged == Some(true))
489 .unwrap_or(false);
490 if merged {
491 findings.push(Finding {
493 severity: Severity::Warning,
494 task_id: task_label,
495 message: format!(
496 "done with no pr_number but branch has merged PR #{}",
497 pr_num
498 ),
499 fix: FixAction::LinkPr {
500 store_id,
501 pr_number: pr_num,
502 },
503 });
504 } else {
505 findings.push(Finding {
507 severity: Severity::Error,
508 task_id: task_label,
509 message: format!(
510 "done with no pr_number but branch has unmerged PR #{} — done is terminal, file a new task to finish the work",
511 pr_num
512 ),
513 fix: FixAction::None,
514 });
515 }
516 }
517 Ok(None) => {
518 findings.push(Finding {
519 severity: Severity::Error,
520 task_id: task_label,
521 message: describe_unrecovered_work(task).await,
522 fix: FixAction::None,
523 });
524 }
525 Err(_) => {
526 findings.push(Finding {
527 severity: Severity::Error,
528 task_id: task_label,
529 message: "done but no PR number recorded and branch lookup failed"
530 .to_string(),
531 fix: FixAction::None,
532 });
533 }
534 }
535 } else {
536 findings.push(Finding {
537 severity: Severity::Error,
538 task_id: task_label,
539 message: "done but no branch or PR recorded".to_string(),
540 fix: FixAction::None,
541 });
542 }
543 }
544 }
545}
546
547async fn describe_unrecovered_work(task: &Task) -> String {
552 const HEAD: &str = "done but no PR was ever created";
553 if task.worktree.is_empty() || task.worktree_cleaned {
554 return format!("{HEAD} and the worktree is gone — any work is lost");
555 }
556 let wt = Path::new(&task.worktree);
557 if !wt.exists() || !wt.join(".git").exists() {
558 return format!("{HEAD} and the worktree is gone — any work is lost");
559 }
560
561 let has_dirty = tokio::process::Command::new("git")
563 .args(["status", "--porcelain"])
564 .current_dir(wt)
565 .output()
566 .await
567 .map(|o| !o.stdout.is_empty())
568 .unwrap_or(false);
569
570 if has_dirty {
571 return format!("{HEAD}; uncommitted changes left in {}", task.worktree);
572 }
573
574 let has_unpushed = tokio::process::Command::new("git")
576 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
577 .current_dir(wt)
578 .output()
579 .await
580 .map(|o| !o.stdout.is_empty())
581 .unwrap_or(false);
582
583 if has_unpushed {
584 return format!("{HEAD}; unpushed commits left in {}", task.worktree);
585 }
586
587 let branch_on_remote = tokio::process::Command::new("git")
589 .args(["ls-remote", "--heads", "origin", &task.branch])
590 .current_dir(wt)
591 .output()
592 .await
593 .map(|o| !o.stdout.is_empty())
594 .unwrap_or(false);
595
596 if branch_on_remote {
597 return format!("{HEAD}; branch '{}' is pushed with no PR", task.branch);
598 }
599
600 HEAD.to_string()
601}
602
603async fn check_dirty_worktree(task: &Task, findings: &mut Vec<Finding>) {
605 if task.worktree.is_empty() || task.worktree_cleaned {
606 return;
607 }
608 let wt = Path::new(&task.worktree);
609 if !wt.exists() || !wt.join(".git").exists() {
610 return;
611 }
612 let output = tokio::process::Command::new("git")
613 .args(["status", "--porcelain"])
614 .current_dir(wt)
615 .output()
616 .await;
617 if let Ok(out) = output {
618 if !out.stdout.is_empty() {
619 findings.push(Finding {
620 severity: Severity::Error,
621 task_id: task_label(task),
622 message: format!(
623 "done but worktree has uncommitted changes: {} — done is terminal, file a new task to land them",
624 task.worktree
625 ),
626 fix: FixAction::None,
627 });
628 }
629 }
630}
631
632async fn check_unpushed_commits(task: &Task, findings: &mut Vec<Finding>) {
634 if task.worktree.is_empty() || task.worktree_cleaned || task.branch.is_empty() {
635 return;
636 }
637 let wt = Path::new(&task.worktree);
638 if !wt.exists() || !wt.join(".git").exists() {
639 return;
640 }
641 let output = tokio::process::Command::new("git")
642 .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
643 .current_dir(wt)
644 .output()
645 .await;
646 if let Ok(out) = output {
647 if !out.stdout.is_empty() {
648 let count = out.stdout.iter().filter(|&&b| b == b'\n').count();
649 findings.push(Finding {
650 severity: Severity::Error,
651 task_id: task_label(task),
652 message: format!(
653 "done but {} unpushed commit(s) in worktree: {} — done is terminal, file a new task to land them",
654 count, task.worktree
655 ),
656 fix: FixAction::None,
657 });
658 }
659 }
660}
661
662fn check_issue_status_mismatch(
664 task: &Task,
665 issue_states: &std::collections::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 (state, _labels) = match issue_states.get(&ext_id) {
674 Some(v) => v,
675 None => return,
676 };
677
678 let issue_closed = state == "closed";
679 let task_done = task.status == TaskStatus::Done;
680 let task_blocked = task.status == TaskStatus::Blocked;
681
682 if issue_closed && !task_done && !task_blocked {
683 findings.push(Finding {
684 severity: Severity::Error,
685 task_id: task_label(task),
686 message: format!(
687 "GitHub issue is closed but SQLite status is '{}'",
688 task.status.as_str()
689 ),
690 fix: FixAction::ReopenIssue { store_id: task.id },
691 });
692 } else if !issue_closed && task_done {
693 findings.push(Finding {
694 severity: Severity::Warning,
695 task_id: task_label(task),
696 message: "SQLite status is 'done' but GitHub issue is still open".to_string(),
697 fix: FixAction::CloseIssue { store_id: task.id },
698 });
699 }
700}
701
702fn check_label_mismatch(
704 task: &Task,
705 issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
706 findings: &mut Vec<Finding>,
707) {
708 let ext_id = match &task.external_id {
709 Some(id) => id.clone(),
710 None => return,
711 };
712
713 let (_state, labels) = match issue_states.get(&ext_id) {
714 Some(v) => v,
715 None => return,
716 };
717
718 let expected_label = format!("status:{}", task.status.as_str());
719 let has_expected = labels.iter().any(|l| l == &expected_label);
720 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
721
722 if !has_expected {
723 let actual = if status_labels.is_empty() {
724 "none".to_string()
725 } else {
726 status_labels
727 .iter()
728 .map(|s| s.as_str())
729 .collect::<Vec<_>>()
730 .join(", ")
731 };
732 findings.push(Finding {
733 severity: Severity::Warning,
734 task_id: task_label(task),
735 message: format!(
736 "label mismatch: SQLite='{}' but GitHub labels=[{}]",
737 task.status.as_str(),
738 actual
739 ),
740 fix: FixAction::SyncLabels { store_id: task.id },
741 });
742 }
743}
744
745async fn check_stale_in_progress(
747 task: &Task,
748 repo: &str,
749 tmux: &TmuxManager,
750 findings: &mut Vec<Finding>,
751) {
752 let task_id_str = match &task.external_id {
753 Some(id) => id.clone(),
754 None => format!("internal:{}", task.id),
755 };
756 let session = tmux.session_name(repo, &task_id_str);
757 if !tmux.session_exists(&session).await {
758 findings.push(Finding {
759 severity: Severity::Error,
760 task_id: task_label(task),
761 message: format!(
762 "status is 'in_progress' but no tmux session '{}' found",
763 session
764 ),
765 fix: FixAction::ResetToRouted { store_id: task.id },
766 });
767 }
768}
769
770async fn check_orphaned_worktrees(all_tasks: &[Task], findings: &mut Vec<Finding>) {
772 let worktrees_dir = match crate::home::worktrees_dir() {
773 Ok(d) => d,
774 Err(_) => return,
775 };
776
777 let project_dirs = match std::fs::read_dir(&worktrees_dir) {
778 Ok(d) => d,
779 Err(_) => return,
780 };
781
782 let task_by_worktree: std::collections::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
798 .file_type()
799 .map(|t| t.is_dir())
800 .unwrap_or(false)
801 {
802 continue;
803 }
804 let branch_dirs = match std::fs::read_dir(project_entry.path()) {
805 Ok(d) => d,
806 Err(_) => continue,
807 };
808 for branch_entry in branch_dirs {
809 let branch_entry = match branch_entry {
810 Ok(e) => e,
811 Err(e) => {
812 tracing::warn!(err = %e, "error reading project directory entry");
813 continue;
814 }
815 };
816 if !branch_entry
817 .file_type()
818 .map(|t| t.is_dir())
819 .unwrap_or(false)
820 {
821 continue;
822 }
823 let wt_path = branch_entry.path().to_string_lossy().to_string();
824
825 let owning_task = task_by_worktree.get(wt_path.as_str()).copied();
826
827 match owning_task {
828 Some(task) => {
829 if task.status == TaskStatus::Done && !task.worktree_cleaned {
830 let has_work = worktree_has_work(Path::new(&wt_path), &task.branch).await;
832 if has_work {
833 findings.push(Finding {
835 severity: Severity::Warning,
836 task_id: task_label(task),
837 message: format!("done but worktree has unsaved work: {}", wt_path),
838 fix: FixAction::None,
839 });
840 } else {
841 findings.push(Finding {
842 severity: Severity::Warning,
843 task_id: task_label(task),
844 message: format!("done but worktree not cleaned up: {}", wt_path),
845 fix: FixAction::CleanWorktree { store_id: task.id },
846 });
847 }
848 }
849 }
850 None => {
851 findings.push(Finding {
852 severity: Severity::Warning,
853 task_id: "?".to_string(),
854 message: format!("orphaned worktree (no task owns it): {}", wt_path),
855 fix: FixAction::RemoveOrphanedWorktree {
856 path: wt_path.clone(),
857 },
858 });
859 }
860 }
861 }
862 }
863}
864
865fn check_pr_merged_not_done(
867 task: &Task,
868 pr_states: &std::collections::HashMap<u64, (bool, String)>,
869 findings: &mut Vec<Finding>,
870) {
871 let pr_num = match task.pr_number {
872 Some(n) => n as u64,
873 None => return,
874 };
875
876 if let Some((merged, _)) = pr_states.get(&pr_num) {
877 if *merged {
878 findings.push(Finding {
879 severity: Severity::Error,
880 task_id: task_label(task),
881 message: format!(
882 "PR #{} is merged but task status is '{}'",
883 pr_num,
884 task.status.as_str()
885 ),
886 fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
887 });
888 }
889 }
890}
891
892async fn check_dead_review_session(
894 task: &Task,
895 repo: &str,
896 tmux: &TmuxManager,
897 findings: &mut Vec<Finding>,
898) {
899 if !task.review_session_expected {
900 return;
901 }
902 let task_id_str = match &task.external_id {
903 Some(id) => id.clone(),
904 None => format!("internal:{}", task.id),
905 };
906 let review_task_id = format!("{}-review", task_id_str);
907 let session = tmux.session_name(repo, &review_task_id);
908 if !tmux.session_exists(&session).await {
909 findings.push(Finding {
910 severity: Severity::Error,
911 task_id: task_label(task),
912 message: format!(
913 "status is 'in_review' with review_session_expected but no review session '{}' found",
914 session
915 ),
916 fix: FixAction::ResetDeadReview { store_id: task.id },
917 });
918 }
919}
920
921async fn worktree_has_work(wt: &Path, branch: &str) -> bool {
923 if !wt.join(".git").exists() {
924 return false;
925 }
926 let has_dirty = tokio::process::Command::new("git")
927 .args(["status", "--porcelain"])
928 .current_dir(wt)
929 .output()
930 .await
931 .map(|o| !o.stdout.is_empty())
932 .unwrap_or(false);
933 if has_dirty {
934 return true;
935 }
936 if !branch.is_empty() {
937 let has_unpushed = tokio::process::Command::new("git")
938 .args(["log", &format!("origin/{}..HEAD", branch), "--oneline"])
939 .current_dir(wt)
940 .output()
941 .await
942 .map(|o| !o.stdout.is_empty())
943 .unwrap_or(false);
944 if has_unpushed {
945 return true;
946 }
947 }
948 false
949}
950
951async fn apply_fixes(
953 findings: &[Finding],
954 store: &Arc<TaskStore>,
955 repo: &str,
956 backend: &Arc<dyn ExternalBackend>,
957 gh: &GhHttp,
958 dry_run: bool,
959) -> anyhow::Result<()> {
960 let prefix = if dry_run { "[dry-run] " } else { "" };
961 let mut fixed = 0;
962 let mut skipped = 0;
963
964 for f in findings {
965 match &f.fix {
966 FixAction::None => {
967 skipped += 1;
968 continue;
969 }
970 FixAction::LinkPr {
971 store_id,
972 pr_number,
973 } => {
974 if dry_run {
975 println!(
976 " {}would link PR #{} to task #{}",
977 prefix, pr_number, f.task_id
978 );
979 fixed += 1;
980 continue;
981 }
982
983 let _ = store
984 .set_fields(
985 *store_id,
986 &[("pr_number", serde_json::json!(*pr_number as i64))],
987 )
988 .await;
989 println!(" fixed #{}: linked PR #{}", f.task_id, pr_number);
990 fixed += 1;
991 }
992 FixAction::ReopenIssue { store_id } => {
993 let task = store.get(*store_id).await?;
994
995 if dry_run {
996 println!(
997 " {}would reopen GitHub issue + sync labels for #{}",
998 prefix, f.task_id
999 );
1000 fixed += 1;
1001 continue;
1002 }
1003
1004 if let Some(ref ext_id) = task.external_id {
1005 if let Err(e) = gh.reopen_issue(repo, ext_id).await {
1006 eprintln!(" fix failed for #{}: reopen failed: {}", f.task_id, e);
1007 skipped += 1;
1008 continue;
1009 }
1010 let status = task_status_to_backend_status(task.status);
1011 let _ = backend
1012 .update_status(&ExternalId(ext_id.clone()), status)
1013 .await;
1014 println!(" fixed #{}: reopened issue + synced labels", f.task_id);
1015 fixed += 1;
1016 } else {
1017 skipped += 1;
1018 }
1019 }
1020 FixAction::CloseIssue { store_id } => {
1021 let task = store.get(*store_id).await?;
1022
1023 if dry_run {
1024 println!(" {}would close GitHub issue #{}", prefix, f.task_id);
1025 fixed += 1;
1026 continue;
1027 }
1028
1029 if let Some(ref ext_id) = task.external_id {
1030 let status = crate::backends::Status::Done;
1031 if let Err(e) = backend
1032 .update_status(&ExternalId(ext_id.clone()), status)
1033 .await
1034 {
1035 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1036 skipped += 1;
1037 } else {
1038 println!(" fixed #{}: closed GitHub issue", f.task_id);
1039 fixed += 1;
1040 }
1041 } else {
1042 skipped += 1;
1043 }
1044 }
1045 FixAction::SyncLabels { store_id } => {
1046 let task = store.get(*store_id).await?;
1047
1048 if dry_run {
1049 println!(
1050 " {}would sync labels to status:{} for #{}",
1051 prefix,
1052 task.status.as_str(),
1053 f.task_id
1054 );
1055 fixed += 1;
1056 continue;
1057 }
1058
1059 if let Some(ref ext_id) = task.external_id {
1060 let status = task_status_to_backend_status(task.status);
1061 if let Err(e) = backend
1062 .update_status(&ExternalId(ext_id.clone()), status)
1063 .await
1064 {
1065 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1066 skipped += 1;
1067 } else {
1068 println!(" fixed #{}: synced labels", f.task_id);
1069 fixed += 1;
1070 }
1071 } else {
1072 skipped += 1;
1073 }
1074 }
1075 FixAction::ResetToRouted { store_id } => {
1076 if dry_run {
1077 println!(
1078 " {}would reset #{} to 'routed' (re-dispatch on next tick)",
1079 prefix, f.task_id
1080 );
1081 fixed += 1;
1082 continue;
1083 }
1084
1085 if let Err(e) = store.update_status(*store_id, TaskStatus::Routed).await {
1086 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1087 skipped += 1;
1088 continue;
1089 }
1090
1091 let task = store.get(*store_id).await?;
1093 if let Some(ref ext_id) = task.external_id {
1094 let _ = backend
1095 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Routed)
1096 .await;
1097 }
1098 println!(" fixed #{}: reset to routed", f.task_id);
1099 fixed += 1;
1100 }
1101 FixAction::CleanWorktree { store_id } => {
1102 let task = store.get(*store_id).await?;
1103
1104 if dry_run {
1105 println!(
1106 " {}would clean worktree + mark cleaned for #{}",
1107 prefix, f.task_id
1108 );
1109 fixed += 1;
1110 continue;
1111 }
1112
1113 let wt = Path::new(&task.worktree);
1114 if wt.exists() {
1115 match resolve_repo_root(repo).await {
1116 Ok(root) => {
1117 let branch = if task.branch.is_empty() {
1118 None
1119 } else {
1120 Some(task.branch.as_str())
1121 };
1122 let removed = remove_worktree_and_branch(
1123 &f.task_id,
1124 wt,
1125 branch,
1126 Path::new(&root),
1127 task.pr_number.is_some(), )
1129 .await;
1130 if removed {
1131 let _ = store.mark_cleaned(*store_id).await;
1132 println!(" fixed #{}: cleaned worktree", f.task_id);
1133 fixed += 1;
1134 } else {
1135 eprintln!(
1136 " fix failed for #{}: worktree removal failed",
1137 f.task_id
1138 );
1139 skipped += 1;
1140 }
1141 }
1142 Err(e) => {
1143 eprintln!(
1144 " fix failed for #{}: cannot resolve repo root: {}",
1145 f.task_id, e
1146 );
1147 skipped += 1;
1148 }
1149 }
1150 } else {
1151 let _ = store.mark_cleaned(*store_id).await;
1153 println!(
1154 " fixed #{}: marked worktree cleaned (already gone)",
1155 f.task_id
1156 );
1157 fixed += 1;
1158 }
1159 }
1160 FixAction::RemoveOrphanedWorktree { path } => {
1161 if dry_run {
1162 println!(" {}would remove orphaned worktree: {}", prefix, path);
1163 fixed += 1;
1164 continue;
1165 }
1166
1167 let removed = cleanup_orphaned_worktree(&f.task_id, Path::new(path)).await;
1168 if removed {
1169 println!(" fixed: removed orphaned worktree {}", path);
1170 fixed += 1;
1171 } else {
1172 eprintln!(" fix failed: could not remove orphaned worktree {}", path);
1173 skipped += 1;
1174 }
1175 }
1176 FixAction::MarkDoneFromMergedPr { store_id } => {
1177 let task = store.get(*store_id).await?;
1178
1179 if dry_run {
1180 println!(
1181 " {}would mark #{} done + close issue + clean worktree",
1182 prefix, f.task_id
1183 );
1184 fixed += 1;
1185 continue;
1186 }
1187
1188 if let Err(e) = store.update_status(*store_id, TaskStatus::Done).await {
1190 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1191 skipped += 1;
1192 continue;
1193 }
1194
1195 if let Some(ref ext_id) = task.external_id {
1197 let _ = backend
1198 .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Done)
1199 .await;
1200 }
1201
1202 if !task.worktree.is_empty() && !task.worktree_cleaned {
1204 let wt = Path::new(&task.worktree);
1205 if wt.exists() {
1206 if let Ok(root) = resolve_repo_root(repo).await {
1207 let branch = if task.branch.is_empty() {
1208 None
1209 } else {
1210 Some(task.branch.as_str())
1211 };
1212 let removed = remove_worktree_and_branch(
1213 &f.task_id,
1214 wt,
1215 branch,
1216 Path::new(&root),
1217 true, )
1219 .await;
1220 if removed {
1221 let _ = store.mark_cleaned(*store_id).await;
1222 }
1223 }
1224 } else {
1225 let _ = store.mark_cleaned(*store_id).await;
1226 }
1227 }
1228
1229 println!(" fixed #{}: marked done + closed issue", f.task_id);
1230 fixed += 1;
1231 }
1232 FixAction::ResetDeadReview { store_id } => {
1233 if dry_run {
1234 println!(
1235 " {}would reset #{} review_session_expected=0 + set needs_review",
1236 prefix, f.task_id
1237 );
1238 fixed += 1;
1239 continue;
1240 }
1241
1242 let _ = store
1244 .set_fields(
1245 *store_id,
1246 &[("review_session_expected", serde_json::json!(false))],
1247 )
1248 .await;
1249 if let Err(e) = store
1250 .update_status(*store_id, TaskStatus::NeedsReview)
1251 .await
1252 {
1253 eprintln!(" fix failed for #{}: {}", f.task_id, e);
1254 skipped += 1;
1255 continue;
1256 }
1257
1258 let task = store.get(*store_id).await?;
1260 if let Some(ref ext_id) = task.external_id {
1261 let _ = backend
1262 .update_status(
1263 &ExternalId(ext_id.clone()),
1264 crate::backends::Status::NeedsReview,
1265 )
1266 .await;
1267 }
1268 println!(
1269 " fixed #{}: reset review session + set needs_review",
1270 f.task_id
1271 );
1272 fixed += 1;
1273 }
1274 }
1275 }
1276
1277 let action = if dry_run { "would fix" } else { "fixed" };
1278 println!("\n{} {}, skipped {}.", action, fixed, skipped);
1279
1280 if !dry_run && skipped > 0 {
1281 println!("Reported issues with no automatic fix need a new issue or task.");
1282 }
1283
1284 Ok(())
1285}
1286
1287fn task_label(task: &Task) -> String {
1288 match &task.external_id {
1289 Some(id) => id.clone(),
1290 None => format!("internal:{}", task.id),
1291 }
1292}
1293
1294fn task_status_to_backend_status(status: TaskStatus) -> crate::backends::Status {
1295 match status {
1296 TaskStatus::New => crate::backends::Status::New,
1297 TaskStatus::Routed => crate::backends::Status::Routed,
1298 TaskStatus::InProgress => crate::backends::Status::InProgress,
1299 TaskStatus::Done => crate::backends::Status::Done,
1300 TaskStatus::Blocked => crate::backends::Status::Blocked,
1301 TaskStatus::InReview => crate::backends::Status::InReview,
1302 TaskStatus::NeedsReview => crate::backends::Status::NeedsReview,
1303 TaskStatus::Waiting => crate::backends::Status::Waiting,
1304 }
1305}
1306
1307fn collect_valid_external_issue_numbers<'a>(
1308 tasks: impl IntoIterator<Item = &'a Task>,
1309 repo: &str,
1310 caller_context: &str,
1311) -> Vec<u64> {
1312 let mut issue_numbers = Vec::new();
1313
1314 for task in tasks {
1315 let Some(external_id) = task.external_id.as_deref() else {
1316 continue;
1317 };
1318
1319 match external_id.parse::<u64>() {
1320 Ok(0) => {
1321 tracing::warn!(
1322 repo,
1323 caller_context,
1324 store_id = task.id,
1325 external_id,
1326 "dropping invalid external_id before batch issue-state query"
1327 );
1328 }
1329 Ok(issue_number) => issue_numbers.push(issue_number),
1330 Err(error) => {
1331 tracing::warn!(
1332 repo,
1333 caller_context,
1334 store_id = task.id,
1335 external_id,
1336 error = %error,
1337 "skipping non-numeric external_id in batch issue-state query"
1338 );
1339 }
1340 }
1341 }
1342
1343 issue_numbers.sort_unstable();
1344 issue_numbers.dedup();
1345 issue_numbers
1346}
1347
1348#[cfg(test)]
1349mod tests {
1350 use super::collect_valid_external_issue_numbers;
1351 use crate::store::{Task, TaskStatus};
1352
1353 fn task(id: i64, external_id: Option<&str>, origin: &str) -> Task {
1354 Task {
1355 id,
1356 external_id: external_id.map(ToOwned::to_owned),
1357 repo: "owner/repo".to_string(),
1358 origin: origin.to_string(),
1359 title: String::new(),
1360 body: String::new(),
1361 status: TaskStatus::New,
1362 source: String::new(),
1363 source_id: String::new(),
1364 author: String::new(),
1365 url: String::new(),
1366 labels: Vec::new(),
1367 agent: None,
1368 model: None,
1369 complexity: String::new(),
1370 estimate: 0,
1371 route_reason: String::new(),
1372 agent_profile: String::new(),
1373 selected_skills: String::new(),
1374 route_attempts: 0,
1375 attempts: 0,
1376 branch: String::new(),
1377 worktree: String::new(),
1378 worktree_cleaned: false,
1379 summary: String::new(),
1380 last_error: String::new(),
1381 parent_id: None,
1382 block_reason: None,
1383 pr_number: None,
1384 pr_review_context: String::new(),
1385 last_review_ts: String::new(),
1386 review_ts_map: String::new(),
1387 last_comment_review_ts: String::new(),
1388 merge_conflict_retries: 0,
1389 ci_merge_failures: 0,
1390 pr_create_failures: 0,
1391 push_failures: 0,
1392 network_retries: 0,
1393 review_agent_failures: 0,
1394 review_cycles: 0,
1395 review_invocations: 0,
1396 review_session_expected: false,
1397 needs_review_refires: 0,
1398 input_tokens: 0,
1399 output_tokens: 0,
1400 input_cost_usd: 0.0,
1401 output_cost_usd: 0.0,
1402 total_cost_usd: 0.0,
1403 model_reroute_chain: String::new(),
1404 limit_reroute_chain: String::new(),
1405 memory: Vec::new(),
1406 delegations: Vec::new(),
1407 auto_unblock_count: 0,
1408 auto_unblock_last_at: String::new(),
1409 auto_unblock_last_reason: String::new(),
1410 ci_recovery_count: 0,
1411 no_code_reroutes: 0,
1412 no_code_last_agent: String::new(),
1413 created_at: String::new(),
1414 updated_at: String::new(),
1415 }
1416 }
1417
1418 #[test]
1419 fn collect_valid_external_issue_numbers_filters_zero_and_non_numeric_ids() {
1420 let tasks = [
1421 task(1, Some("42"), "github"),
1422 task(2, Some("0"), "github"),
1423 task(3, Some("not-a-number"), "github"),
1424 task(4, Some("42"), "github"),
1425 ];
1426
1427 let issue_numbers =
1428 collect_valid_external_issue_numbers(tasks.iter(), "owner/repo", "test");
1429
1430 assert_eq!(issue_numbers, vec![42]);
1431 }
1432}
1433
1434#[cfg(test)]
1435mod done_is_terminal_tests {
1436 use super::*;
1437 use crate::backends::test_helpers::NoopBackend;
1438 use crate::store::{NewTask, TaskStore};
1439
1440 fn git(dir: &Path, args: &[&str]) {
1441 let ok = std::process::Command::new("git")
1442 .args(args)
1443 .current_dir(dir)
1444 .env("GIT_AUTHOR_NAME", "t")
1445 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1446 .env("GIT_COMMITTER_NAME", "t")
1447 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1448 .output()
1449 .expect("git runs")
1450 .status
1451 .success();
1452 assert!(ok, "git {args:?} failed");
1453 }
1454
1455 #[tokio::test]
1458 async fn fix_leaves_done_task_status_alone() {
1459 let dir = tempfile::tempdir().unwrap();
1460 let wt = dir.path();
1461 git(wt, &["init", "-b", "main"]);
1462 std::fs::write(wt.join("a.txt"), "a").unwrap();
1463 git(wt, &["add", "-A"]);
1464 git(wt, &["commit", "-m", "init"]);
1465 std::fs::write(wt.join("b.txt"), "b").unwrap();
1467
1468 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1469 let store_id = store
1470 .create(&NewTask {
1471 external_id: Some("58".to_string()),
1472 repo: "owner/repo".to_string(),
1473 origin: "github".to_string(),
1474 title: "Task".to_string(),
1475 body: String::new(),
1476 source: "cron".to_string(),
1477 source_id: "code-development".to_string(),
1478 author: "bot".to_string(),
1479 url: String::new(),
1480 labels: vec![],
1481 parent_id: None,
1482 })
1483 .await
1484 .unwrap();
1485 store
1486 .set_fields(
1487 store_id,
1488 &[
1489 ("worktree", serde_json::json!(wt.display().to_string())),
1490 ("branch", serde_json::json!("main")),
1491 ],
1492 )
1493 .await
1494 .unwrap();
1495 store
1496 .update_status(store_id, TaskStatus::Done)
1497 .await
1498 .unwrap();
1499 let task = store.get(store_id).await.unwrap();
1500
1501 let mut findings = Vec::new();
1502 check_dirty_worktree(&task, &mut findings).await;
1503 check_unpushed_commits(&task, &mut findings).await;
1504 assert!(
1505 !findings.is_empty(),
1506 "a done task with uncommitted work must still be reported"
1507 );
1508 for f in &findings {
1509 assert!(
1510 matches!(f.fix, FixAction::None),
1511 "done-task finding must carry no automatic fix: {}",
1512 f.message
1513 );
1514 }
1515
1516 let backend: Arc<dyn ExternalBackend> = Arc::new(NoopBackend);
1517 let gh = GhHttp::new().unwrap();
1518 apply_fixes(&findings, &store, "owner/repo", &backend, &gh, false)
1519 .await
1520 .unwrap();
1521
1522 assert_eq!(
1523 store.get(store_id).await.unwrap().status,
1524 TaskStatus::Done,
1525 "doctor --fix must not move a done task out of done"
1526 );
1527 }
1528}