Skip to main content

orch/cli/
doctor.rs

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