Skip to main content

orch/cli/
doctor.rs

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
27/// What kind of automatic repair can be applied to a finding.
28///
29/// Nothing here moves a done task out of done. Done is terminal, so a done task
30/// that looks wrong is reported and left alone (#3780)
31enum FixAction {
32    /// PR exists but pr_number not recorded — link it in the store.
33    LinkPr { store_id: i64, pr_number: u64 },
34    /// GitHub issue is closed but task is not done — reopen issue, sync labels.
35    ReopenIssue { store_id: i64 },
36    /// Task is done but GitHub issue still open — close issue.
37    CloseIssue { store_id: i64 },
38    /// GitHub label doesn't match SQLite status — sync labels.
39    SyncLabels { store_id: i64 },
40    /// Stale in_progress — no tmux session — reset to routed.
41    ResetToRouted { store_id: i64 },
42    /// Done task with uncleaned worktree that has no uncommitted/unpushed work.
43    CleanWorktree { store_id: i64 },
44    /// Orphaned worktree with no owning task — remove directory.
45    RemoveOrphanedWorktree { path: String },
46    /// PR merged but task not done — set done, close issue, clean worktree.
47    MarkDoneFromMergedPr { store_id: i64 },
48    /// Dead review session — reset to needs_review.
49    ResetDeadReview { store_id: i64 },
50    /// No automatic fix possible.
51    None,
52}
53
54/// A single diagnostic finding from `orch service doctor`.
55struct 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
77/// Run all diagnostic checks and print findings.
78///
79/// When called outside a project directory (no `.orch.yml`), runs across all
80/// repos found in the store. When called inside a project, scopes to that repo.
81pub 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    // Resolve repos to check: current project if available, otherwise all repos in store.
87    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    // Spinner: show progress on stderr while checks run (erase with \r on done).
99    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            // Existing expensive path: load active + recent done tasks and batch GraphQL queries.
141            // Only load active tasks + done tasks from the last 7 days — avoids
142            // fetching hundreds of historical done tasks on every run.
143            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            // Batch-fetch all PR states needed for checks 1 and 8 in a single GraphQL call.
165            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            // 1. Done tasks with no merged PR (recent only — skip internal no-PR tasks)
186            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            // 2. Done tasks with dirty worktrees (recent only — filesystem check, fast)
197            for task in &recent_done_tasks {
198                check_dirty_worktree(task, &mut findings).await;
199            }
200
201            // 3. Done tasks with unpushed commits (recent only — filesystem check, fast)
202            for task in &recent_done_tasks {
203                check_unpushed_commits(task, &mut findings).await;
204            }
205
206            // 4+5. Issue status and label checks (active external tasks only).
207            // Batch-fetch all issue states and labels in a single GraphQL call.
208            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            // 6. Stale in_progress tasks (no tmux session)
228            for task in &in_progress_tasks {
229                check_stale_in_progress(task, repo, tmux, &mut findings).await;
230            }
231
232            // 7. Orphaned worktrees (needs all tasks to match worktree paths to owners)
233            check_orphaned_worktrees(&all_tasks, &mut findings).await;
234
235            // 8. PR merged but task not done (active tasks only — uses batched pr_states)
236            for task in &active_tasks {
237                check_pr_merged_not_done(task, &pr_states, &mut findings);
238            }
239
240            // 9. Dead review sessions
241            for task in &in_review_tasks {
242                check_dead_review_session(task, repo, tmux, &mut findings).await;
243            }
244        } else {
245            // Fast path: only consider active (non-done) tasks and open PRs via REST.
246            let active_tasks = store.list_active(repo).await?;
247
248            // Fetch open PRs for the repo (paginated REST call)
249            let open_prs: Vec<serde_json::Value> =
250                gh.list_open_pulls(repo).await.unwrap_or_default();
251
252            // Build lookup maps: pr_number -> pr, head_ref -> pr
253            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            // Active tasks: perform lightweight checks
265            let external_active: Vec<_> = active_tasks
266                .iter()
267                .filter(|t| t.origin != "internal")
268                .collect();
269            for task in &active_tasks {
270                // Stale in_progress
271                if task.status == TaskStatus::InProgress {
272                    check_stale_in_progress(task, repo, tmux, &mut findings).await;
273                }
274
275                // Dead review sessions
276                if task.status == TaskStatus::InReview {
277                    check_dead_review_session(task, repo, tmux, &mut findings).await;
278                }
279
280                // PR merged check for tasks that have a PR recorded — make one REST call per active task (few)
281                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                        // Ignore transient failures in fast path; the full path will surface them.
297                    }
298                } else if !task.branch.is_empty() {
299                    // If task has a branch, check if there's an open PR for it
300                    if let Some(pr) = pr_by_head.get(&task.branch) {
301                        // PR exists and is open — link PR (warning) unless already linked
302                        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            // Batch issue state/label checks for external active tasks
323            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            // Orphaned PRs: open PRs that don't match any active task by number or head ref
342            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            // A done task still owns its worktree, so include every task here
375            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        // Apply per-repo fixes immediately so store_id lookups are in scope.
384        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 we didn't already print+fix per-repo above (non-fix mode), print summary now.
411    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
438/// Check 1: Done task with no merged PR.
439/// `pr_states` is a pre-fetched cache of pr_number → (merged, state).
440async 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            // Use cached state; fall back to live fetch if missing (e.g. new PR since batch)
453            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            // No PR recorded — check if branch has a PR
482            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                        // PR exists on GitHub but not linked — check merge state
493                        let merged = gh
494                            .get_pr(repo, pr_num)
495                            .await
496                            .is_ok_and(|pr| pr.merged == Some(true));
497                        if merged {
498                            // PR is merged — just link it
499                            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                            // PR not merged — shouldn't be done
512                            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
545/// Describe what a done task with no PR left behind in its worktree.
546///
547/// Report only: the status stays done, so the message tells the operator what
548/// work is sitting there and would otherwise be lost (#3780)
549async 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    // Check for dirty files
560    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    // Check for unpushed commits
572    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    // Branch pushed, no PR
584    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
599/// Check 2: Done task with dirty worktree (uncommitted changes).
600async 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
628/// Check 3: Done task with unpushed commits.
629async 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
662/// Check 4: Closed GitHub issue with non-done SQLite status (and vice versa).
663fn 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
701/// Check 5: Label/status mismatch.
702fn 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
743/// Check 6: Stale in_progress task (no tmux session).
744async 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
765/// Check 7: Orphaned worktrees.
766async 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    // Build a HashMap for O(1) worktree-path → task lookups instead of O(T) linear scan.
783    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                        // Check if worktree has uncommitted/unpushed work
822                        let has_work = worktree_has_work(Path::new(&wt_path), &task.branch).await;
823                        if has_work {
824                            // Don't auto-clean — there's work to save
825                            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
856/// Check 8: PR merged but task not done (uses pre-fetched pr_states cache).
857fn 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
882/// Check 9: Dead review sessions. Callers pass only in_review tasks.
883async 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
905/// Check if a worktree has uncommitted changes or unpushed commits.
906async 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
933/// Attempt automatic fixes for known issues.
934async 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                // Sync labels if external
1070                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(), // keep remote branch if PR exists
1106                            )
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                    // Directory already gone — just mark cleaned
1130                    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                // Set status to done
1167                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                // Close GitHub issue + sync labels
1174                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                // Clean worktree if present
1181                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, // keep remote — PR is merged
1196                            )
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                // Sync labels
1227                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    /// `doctor --fix` must leave a done task done, even when its worktree still
1453    /// holds uncommitted work. Follow-up work gets a new task (#3780).
1454    #[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        // Uncommitted change — the state that used to trigger commit + push + needs_review.
1463        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}