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