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 anyhow::Context;
11use std::io::Write;
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::sync::Arc;
15
16/// What kind of automatic repair can be applied to a finding.
17enum FixAction {
18    /// Commit dirty worktree, push, create PR, reopen issue, set needs_review.
19    CommitPushCreatePr { store_id: i64 },
20    /// Push unpushed commits, create PR, reopen issue, set needs_review.
21    PushCreatePr { store_id: i64 },
22    /// Branch is pushed but no PR — create PR, reopen issue, set needs_review.
23    CreatePr { store_id: i64 },
24    /// PR exists but not merged — link PR, reopen issue, set needs_review.
25    LinkPrAndReopen { store_id: i64, pr_number: u64 },
26    /// PR exists but pr_number not recorded — link it in the store.
27    LinkPr { store_id: i64, pr_number: u64 },
28    /// GitHub issue is closed but task is not done — reopen issue, sync labels.
29    ReopenIssue { store_id: i64 },
30    /// Task is done but GitHub issue still open — close issue.
31    CloseIssue { store_id: i64 },
32    /// GitHub label doesn't match SQLite status — sync labels.
33    SyncLabels { store_id: i64 },
34    /// Stale in_progress — no tmux session — reset to routed.
35    ResetToRouted { store_id: i64 },
36    /// Done task with uncleaned worktree that has no uncommitted/unpushed work.
37    CleanWorktree { store_id: i64 },
38    /// Orphaned worktree with no owning task — remove directory.
39    RemoveOrphanedWorktree { path: String },
40    /// PR merged but task not done — set done, close issue, clean worktree.
41    MarkDoneFromMergedPr { store_id: i64 },
42    /// Dead review session — reset to needs_review.
43    ResetDeadReview { store_id: i64 },
44    /// No automatic fix possible.
45    None,
46}
47
48/// A single diagnostic finding from `orch service doctor`.
49struct Finding {
50    severity: Severity,
51    task_id: String,
52    message: String,
53    fix: FixAction,
54}
55
56#[derive(Clone, Copy)]
57enum Severity {
58    Error,
59    Warning,
60}
61
62impl Severity {
63    fn symbol(self) -> &'static str {
64        match self {
65            Self::Error => "ERR",
66            Self::Warning => "WARN",
67        }
68    }
69}
70
71/// Run all diagnostic checks and print findings.
72///
73/// When called outside a project directory (no `.orch.yml`), runs across all
74/// repos found in the store. When called inside a project, scopes to that repo.
75pub async fn run(full: bool, fix: bool, dry_run: bool) -> anyhow::Result<()> {
76    let store = Arc::new(crate::cli::init_store().await?);
77    let gh = GhHttp::new()?;
78    let tmux = TmuxManager::new();
79
80    // Resolve repos to check: current project if available, otherwise all repos in store.
81    let repos: Vec<String> = match config::get_current_repo() {
82        Ok(repo) => vec![repo],
83        Err(_) => {
84            let all = store.distinct_repos().await?;
85            if all.is_empty() {
86                println!("No tasks found in store.");
87                return Ok(());
88            }
89            all
90        }
91    };
92
93    // Spinner: show progress on stderr while checks run (erase with \r on done).
94    let spinner_running = Arc::new(AtomicBool::new(true));
95    let spinner_flag = spinner_running.clone();
96    let spinner_task = tokio::spawn(async move {
97        let frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
98        let mut i = 0usize;
99        while spinner_flag.load(Ordering::Relaxed) {
100            eprint!(
101                "\r{} Running orch service doctor…",
102                frames[i % frames.len()]
103            );
104            let _ = std::io::stderr().flush();
105            tokio::time::sleep(std::time::Duration::from_millis(80)).await;
106            i += 1;
107        }
108        eprint!("\r");
109        let _ = std::io::stderr().flush();
110    });
111
112    let result = run_checks(full, fix, dry_run, &repos, &store, &gh, &tmux).await;
113
114    spinner_running.store(false, Ordering::Relaxed);
115    let _ = spinner_task.await;
116
117    result
118}
119
120async fn run_checks(
121    full: bool,
122    fix: bool,
123    dry_run: bool,
124    repos: &[String],
125    store: &Arc<TaskStore>,
126    gh: &GhHttp,
127    tmux: &TmuxManager,
128) -> anyhow::Result<()> {
129    let mut all_findings: Vec<Finding> = Vec::new();
130
131    for repo in repos {
132        let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
133        let mut findings: Vec<Finding> = Vec::new();
134        if full {
135            // Existing expensive path: load active + recent done tasks and batch GraphQL queries.
136            // Only load active tasks + done tasks from the last 7 days — avoids
137            // fetching hundreds of historical done tasks on every run.
138            let cutoff = chrono::Utc::now() - chrono::Duration::days(7);
139            let cutoff_str = cutoff.format("%Y-%m-%dT%H:%M:%SZ").to_string();
140            let all_tasks = store.list_for_doctor(repo, &cutoff_str).await?;
141
142            let recent_done_tasks: Vec<_> = all_tasks
143                .iter()
144                .filter(|t| t.status == TaskStatus::Done)
145                .collect();
146            let active_tasks: Vec<_> = all_tasks
147                .iter()
148                .filter(|t| t.status != TaskStatus::Done)
149                .collect();
150            let in_progress_tasks: Vec<_> = all_tasks
151                .iter()
152                .filter(|t| t.status == TaskStatus::InProgress)
153                .collect();
154            let in_review_tasks: Vec<_> = all_tasks
155                .iter()
156                .filter(|t| t.status == TaskStatus::InReview)
157                .collect();
158
159            // Batch-fetch all PR states needed for checks 1 and 8 in a single GraphQL call.
160            let pr_numbers_to_fetch: Vec<u64> = {
161                let mut nums: Vec<u64> = recent_done_tasks
162                    .iter()
163                    .filter(|t| !(t.origin == "internal" && t.external_id.is_none()))
164                    .filter_map(|t| t.pr_number.map(|n| n as u64))
165                    .chain(
166                        active_tasks
167                            .iter()
168                            .filter_map(|t| t.pr_number.map(|n| n as u64)),
169                    )
170                    .collect();
171                nums.sort_unstable();
172                nums.dedup();
173                nums
174            };
175            let pr_states = gh
176                .batch_get_pr_states(repo, &pr_numbers_to_fetch)
177                .await
178                .unwrap_or_default();
179
180            // 1. Done tasks with no merged PR (recent only — skip internal no-PR tasks)
181            for task in &recent_done_tasks {
182                if task.origin == "internal" && task.external_id.is_none() {
183                    continue;
184                }
185                if task.pr_number.is_none() && task.external_id.is_none() {
186                    continue;
187                }
188                check_done_no_merged_pr(task, gh, &pr_states, &mut findings, repo).await;
189            }
190
191            // 2. Done tasks with dirty worktrees (recent only — filesystem check, fast)
192            for task in &recent_done_tasks {
193                check_dirty_worktree(task, &mut findings).await;
194            }
195
196            // 3. Done tasks with unpushed commits (recent only — filesystem check, fast)
197            for task in &recent_done_tasks {
198                check_unpushed_commits(task, &mut findings).await;
199            }
200
201            // 4+5. Issue status and label checks (active external tasks only).
202            // Batch-fetch all issue states and labels in a single GraphQL call.
203            let external_active: Vec<_> = active_tasks
204                .iter()
205                .copied()
206                .filter(|t| t.origin != "internal")
207                .collect();
208            let issue_numbers = collect_valid_external_issue_numbers(
209                external_active.iter().copied(),
210                repo,
211                "cli::doctor::run_checks(full)",
212            );
213            let issue_states = gh
214                .batch_get_issue_states(repo, &issue_numbers, "cli::doctor::run_checks(full)")
215                .await
216                .unwrap_or_default();
217            for task in &external_active {
218                check_issue_status_mismatch(task, &issue_states, &mut findings);
219                check_label_mismatch(task, &issue_states, &mut findings);
220            }
221
222            // 6. Stale in_progress tasks (no tmux session)
223            for task in &in_progress_tasks {
224                check_stale_in_progress(task, repo, tmux, &mut findings).await;
225            }
226
227            // 7. Orphaned worktrees (needs all tasks to match worktree paths to owners)
228            check_orphaned_worktrees(&all_tasks, &mut findings).await;
229
230            // 8. PR merged but task not done (active tasks only — uses batched pr_states)
231            for task in &active_tasks {
232                check_pr_merged_not_done(task, &pr_states, &mut findings);
233            }
234
235            // 9. Dead review sessions
236            for task in &in_review_tasks {
237                check_dead_review_session(task, repo, tmux, &mut findings).await;
238            }
239        } else {
240            // Fast path: only consider active (non-done) tasks and open PRs via REST.
241            let active_tasks = store.list_active(repo).await?;
242
243            // Fetch open PRs for the repo (paginated REST call)
244            let open_prs: Vec<serde_json::Value> =
245                gh.list_open_pulls(repo).await.unwrap_or_default();
246
247            // Build lookup maps: pr_number -> pr, head_ref -> pr
248            let mut pr_by_number: std::collections::HashMap<u64, serde_json::Value> =
249                std::collections::HashMap::new();
250            let mut pr_by_head: std::collections::HashMap<String, serde_json::Value> =
251                std::collections::HashMap::new();
252            for pr in &open_prs {
253                if let Some(n) = pr.get("number").and_then(|v| v.as_u64()) {
254                    pr_by_number.insert(n, pr.clone());
255                }
256                if let Some(head) = pr.pointer("/head/ref").and_then(|v| v.as_str()) {
257                    pr_by_head.insert(head.to_string(), pr.clone());
258                }
259            }
260
261            // Active tasks: perform lightweight checks
262            let external_active: Vec<_> = active_tasks
263                .iter()
264                .filter(|t| t.origin != "internal")
265                .collect();
266            for task in &active_tasks {
267                // Stale in_progress
268                if task.status == TaskStatus::InProgress {
269                    check_stale_in_progress(task, repo, tmux, &mut findings).await;
270                }
271
272                // Dead review sessions
273                if task.status == TaskStatus::InReview {
274                    check_dead_review_session(task, repo, tmux, &mut findings).await;
275                }
276
277                // PR merged check for tasks that have a PR recorded — make one REST call per active task (few)
278                if let Some(pr_num) = task.pr_number {
279                    match gh.get_pr(repo, pr_num as u64).await {
280                        Ok(pr) => {
281                            if pr.merged.unwrap_or(false) {
282                                findings.push(Finding {
283                                    severity: Severity::Error,
284                                    task_id: task_label(task),
285                                    message: format!(
286                                        "PR #{} is merged but task status is '{}'",
287                                        pr_num,
288                                        task.status.as_str()
289                                    ),
290                                    fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
291                                });
292                            }
293                        }
294                        Err(_) => {
295                            // Ignore transient failures in fast path; the full path will surface them.
296                        }
297                    }
298                } else if !task.branch.is_empty() {
299                    // If task has a branch, check if there's an open PR for it
300                    if let Some(pr) = pr_by_head.get(&task.branch) {
301                        // PR exists and is open — link PR (warning) unless already linked
302                        if task.pr_number.is_none() {
303                            if let Some(n) = pr.get("number").and_then(|v| v.as_u64()) {
304                                findings.push(Finding {
305                                    severity: Severity::Warning,
306                                    task_id: task_label(task),
307                                    message: format!(
308                                        "branch '{}' has open PR #{} not linked in store",
309                                        task.branch, n
310                                    ),
311                                    fix: FixAction::LinkPr {
312                                        store_id: task.id,
313                                        pr_number: n,
314                                    },
315                                });
316                            }
317                        }
318                    }
319                }
320            }
321
322            // Batch issue state/label checks for external active tasks
323            let external_issue_numbers = collect_valid_external_issue_numbers(
324                external_active.iter().copied(),
325                repo,
326                "cli::doctor::run_checks(fast)",
327            );
328            let issue_states = gh
329                .batch_get_issue_states(
330                    repo,
331                    &external_issue_numbers,
332                    "cli::doctor::run_checks(fast)",
333                )
334                .await
335                .unwrap_or_default();
336            for task in &external_active {
337                check_issue_status_mismatch(task, &issue_states, &mut findings);
338                check_label_mismatch(task, &issue_states, &mut findings);
339            }
340
341            // Orphaned PRs: open PRs that don't match any active task by number or head ref
342            for pr in &open_prs {
343                let pr_num = pr.get("number").and_then(|v| v.as_u64());
344                let head = pr
345                    .pointer("/head/ref")
346                    .and_then(|v| v.as_str())
347                    .map(|s| s.to_string());
348                let mut owned = false;
349                if let Some(n) = pr_num {
350                    for t in &active_tasks {
351                        if let Some(pn) = t.pr_number {
352                            if pn as u64 == n {
353                                owned = true;
354                                break;
355                            }
356                        }
357                        if !t.branch.is_empty() && Some(t.branch.clone()) == head {
358                            owned = true;
359                            break;
360                        }
361                    }
362                }
363                if !owned {
364                    let num_str = pr_num
365                        .map(|n| n.to_string())
366                        .unwrap_or_else(|| "?".to_string());
367                    findings.push(Finding {
368                        severity: Severity::Warning,
369                        task_id: format!("pr:{}", num_str),
370                        message: format!("open PR with no active task: #{}", num_str),
371                        fix: FixAction::None,
372                    });
373                }
374            }
375
376            // Orphaned worktrees relative to active tasks only
377            check_orphaned_worktrees(&active_tasks, &mut findings).await;
378        }
379
380        if repos.len() > 1 && !findings.is_empty() {
381            println!("\n=== {} ===", repo);
382        }
383
384        // Apply per-repo fixes immediately so store_id lookups are in scope.
385        if (fix || dry_run) && !findings.is_empty() {
386            let errors = findings
387                .iter()
388                .filter(|f| matches!(f.severity, Severity::Error))
389                .count();
390            let warnings = findings.len() - errors;
391            for f in &findings {
392                println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
393            }
394            println!(
395                "\nFound {} issue(s): {} error(s), {} warning(s)",
396                findings.len(),
397                errors,
398                warnings,
399            );
400            apply_fixes(&findings, store, repo, &backend, gh, dry_run).await?;
401        }
402
403        all_findings.extend(findings);
404    }
405
406    if all_findings.is_empty() {
407        println!("No issues found. All task state is consistent.");
408        return Ok(());
409    }
410
411    // If we didn't already print+fix per-repo above (non-fix mode), print summary now.
412    if !fix && !dry_run {
413        let errors = all_findings
414            .iter()
415            .filter(|f| matches!(f.severity, Severity::Error))
416            .count();
417        let warnings = all_findings.len() - errors;
418
419        for f in &all_findings {
420            println!("[{}] #{}: {}", f.severity.symbol(), f.task_id, f.message);
421        }
422
423        println!(
424            "\nFound {} issue(s): {} error(s), {} warning(s)",
425            all_findings.len(),
426            errors,
427            warnings,
428        );
429
430        if errors > 0 {
431            println!("\nRun `orch service doctor --fix` to attempt automatic repairs.");
432            println!("Run `orch service doctor --dry-run` to preview what --fix would do.");
433        }
434    }
435
436    Ok(())
437}
438
439/// Check 1: Done task with no merged PR.
440/// `pr_states` is a pre-fetched cache of pr_number → (merged, state).
441async fn check_done_no_merged_pr(
442    task: &Task,
443    gh: &GhHttp,
444    pr_states: &std::collections::HashMap<u64, (bool, String)>,
445    findings: &mut Vec<Finding>,
446    repo: &str,
447) {
448    let task_label = task_label(task);
449    let store_id = task.id;
450
451    match task.pr_number {
452        Some(pr_num) => {
453            // Use cached state; fall back to live fetch if missing (e.g. new PR since batch)
454            let (merged, state) = if let Some(s) = pr_states.get(&(pr_num as u64)) {
455                s.clone()
456            } else {
457                match gh.get_pr(repo, pr_num as u64).await {
458                    Ok(pr) => (pr.merged.unwrap_or(false), pr.state),
459                    Err(e) => {
460                        findings.push(Finding {
461                            severity: Severity::Warning,
462                            task_id: task_label,
463                            message: format!(
464                                "done with PR #{} but failed to fetch PR: {}",
465                                pr_num, e
466                            ),
467                            fix: FixAction::None,
468                        });
469                        return;
470                    }
471                }
472            };
473            if !merged {
474                findings.push(Finding {
475                    severity: Severity::Error,
476                    task_id: task_label,
477                    message: format!("done but PR #{} is {} (not merged)", pr_num, state),
478                    fix: FixAction::LinkPrAndReopen {
479                        store_id,
480                        pr_number: pr_num as u64,
481                    },
482                });
483            }
484        }
485        None => {
486            // No PR recorded — check if branch has a PR
487            if !task.branch.is_empty() {
488                match gh.get_pr_number(repo, &task.branch).await {
489                    Ok(Some(pr_num)) => {
490                        // PR exists on GitHub but not linked — check merge state
491                        let merged = gh
492                            .get_pr(repo, pr_num)
493                            .await
494                            .map(|pr| pr.merged == Some(true))
495                            .unwrap_or(false);
496                        if merged {
497                            // PR is merged — just link it
498                            findings.push(Finding {
499                                severity: Severity::Warning,
500                                task_id: task_label,
501                                message: format!(
502                                    "done with no pr_number but branch has merged PR #{}",
503                                    pr_num
504                                ),
505                                fix: FixAction::LinkPr {
506                                    store_id,
507                                    pr_number: pr_num,
508                                },
509                            });
510                        } else {
511                            // PR not merged — shouldn't be done
512                            findings.push(Finding {
513                                severity: Severity::Error,
514                                task_id: task_label,
515                                message: format!(
516                                    "done with no pr_number but branch has unmerged PR #{}",
517                                    pr_num
518                                ),
519                                fix: FixAction::LinkPrAndReopen {
520                                    store_id,
521                                    pr_number: pr_num,
522                                },
523                            });
524                        }
525                    }
526                    Ok(None) => {
527                        // No PR at all — classify based on worktree state
528                        let fix = classify_no_pr_fix(task, store_id).await;
529                        findings.push(Finding {
530                            severity: Severity::Error,
531                            task_id: task_label,
532                            message: "done but no PR was ever created".to_string(),
533                            fix,
534                        });
535                    }
536                    Err(_) => {
537                        findings.push(Finding {
538                            severity: Severity::Error,
539                            task_id: task_label,
540                            message: "done but no PR number recorded and branch lookup failed"
541                                .to_string(),
542                            fix: FixAction::None,
543                        });
544                    }
545                }
546            } else {
547                findings.push(Finding {
548                    severity: Severity::Error,
549                    task_id: task_label,
550                    message: "done but no branch or PR recorded".to_string(),
551                    fix: FixAction::None,
552                });
553            }
554        }
555    }
556}
557
558/// Classify the appropriate fix when a done task has no PR.
559async fn classify_no_pr_fix(task: &Task, store_id: i64) -> FixAction {
560    if task.worktree.is_empty() || task.worktree_cleaned {
561        // Work was lost — worktree already cleaned
562        return FixAction::None;
563    }
564    let wt = Path::new(&task.worktree);
565    if !wt.exists() || !wt.join(".git").exists() {
566        return FixAction::None;
567    }
568
569    // Check for dirty files
570    let has_dirty = tokio::process::Command::new("git")
571        .args(["status", "--porcelain"])
572        .current_dir(wt)
573        .output()
574        .await
575        .map(|o| !o.stdout.is_empty())
576        .unwrap_or(false);
577
578    if has_dirty {
579        return FixAction::CommitPushCreatePr { store_id };
580    }
581
582    // Check for unpushed commits
583    let has_unpushed = tokio::process::Command::new("git")
584        .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
585        .current_dir(wt)
586        .output()
587        .await
588        .map(|o| !o.stdout.is_empty())
589        .unwrap_or(false);
590
591    if has_unpushed {
592        return FixAction::PushCreatePr { store_id };
593    }
594
595    // Branch pushed, no PR
596    let branch_on_remote = tokio::process::Command::new("git")
597        .args(["ls-remote", "--heads", "origin", &task.branch])
598        .current_dir(wt)
599        .output()
600        .await
601        .map(|o| !o.stdout.is_empty())
602        .unwrap_or(false);
603
604    if branch_on_remote {
605        return FixAction::CreatePr { store_id };
606    }
607
608    FixAction::None
609}
610
611/// Check 2: Done task with dirty worktree (uncommitted changes).
612async fn check_dirty_worktree(task: &Task, findings: &mut Vec<Finding>) {
613    if task.worktree.is_empty() || task.worktree_cleaned {
614        return;
615    }
616    let wt = Path::new(&task.worktree);
617    if !wt.exists() || !wt.join(".git").exists() {
618        return;
619    }
620    let output = tokio::process::Command::new("git")
621        .args(["status", "--porcelain"])
622        .current_dir(wt)
623        .output()
624        .await;
625    if let Ok(out) = output {
626        if !out.stdout.is_empty() {
627            findings.push(Finding {
628                severity: Severity::Error,
629                task_id: task_label(task),
630                message: format!(
631                    "done but worktree has uncommitted changes: {}",
632                    task.worktree
633                ),
634                fix: FixAction::CommitPushCreatePr { store_id: task.id },
635            });
636        }
637    }
638}
639
640/// Check 3: Done task with unpushed commits.
641async fn check_unpushed_commits(task: &Task, findings: &mut Vec<Finding>) {
642    if task.worktree.is_empty() || task.worktree_cleaned || task.branch.is_empty() {
643        return;
644    }
645    let wt = Path::new(&task.worktree);
646    if !wt.exists() || !wt.join(".git").exists() {
647        return;
648    }
649    let output = tokio::process::Command::new("git")
650        .args(["log", &format!("origin/{}..HEAD", task.branch), "--oneline"])
651        .current_dir(wt)
652        .output()
653        .await;
654    if let Ok(out) = output {
655        if !out.stdout.is_empty() {
656            let count = out.stdout.iter().filter(|&&b| b == b'\n').count();
657            findings.push(Finding {
658                severity: Severity::Error,
659                task_id: task_label(task),
660                message: format!(
661                    "done but {} unpushed commit(s) in worktree: {}",
662                    count, task.worktree
663                ),
664                fix: FixAction::PushCreatePr { store_id: task.id },
665            });
666        }
667    }
668}
669
670/// Check 4: Closed GitHub issue with non-done SQLite status (and vice versa).
671fn check_issue_status_mismatch(
672    task: &Task,
673    issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
674    findings: &mut Vec<Finding>,
675) {
676    let ext_id = match &task.external_id {
677        Some(id) => id.clone(),
678        None => return,
679    };
680
681    let (state, _labels) = match issue_states.get(&ext_id) {
682        Some(v) => v,
683        None => return,
684    };
685
686    let issue_closed = state == "closed";
687    let task_done = task.status == TaskStatus::Done;
688    let task_blocked = task.status == TaskStatus::Blocked;
689
690    if issue_closed && !task_done && !task_blocked {
691        findings.push(Finding {
692            severity: Severity::Error,
693            task_id: task_label(task),
694            message: format!(
695                "GitHub issue is closed but SQLite status is '{}'",
696                task.status.as_str()
697            ),
698            fix: FixAction::ReopenIssue { store_id: task.id },
699        });
700    } else if !issue_closed && task_done {
701        findings.push(Finding {
702            severity: Severity::Warning,
703            task_id: task_label(task),
704            message: "SQLite status is 'done' but GitHub issue is still open".to_string(),
705            fix: FixAction::CloseIssue { store_id: task.id },
706        });
707    }
708}
709
710/// Check 5: Label/status mismatch.
711fn check_label_mismatch(
712    task: &Task,
713    issue_states: &std::collections::HashMap<String, (String, Vec<String>)>,
714    findings: &mut Vec<Finding>,
715) {
716    let ext_id = match &task.external_id {
717        Some(id) => id.clone(),
718        None => return,
719    };
720
721    let (_state, labels) = match issue_states.get(&ext_id) {
722        Some(v) => v,
723        None => return,
724    };
725
726    let expected_label = format!("status:{}", task.status.as_str());
727    let has_expected = labels.iter().any(|l| l == &expected_label);
728    let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
729
730    if !has_expected {
731        let actual = if status_labels.is_empty() {
732            "none".to_string()
733        } else {
734            status_labels
735                .iter()
736                .map(|s| s.as_str())
737                .collect::<Vec<_>>()
738                .join(", ")
739        };
740        findings.push(Finding {
741            severity: Severity::Warning,
742            task_id: task_label(task),
743            message: format!(
744                "label mismatch: SQLite='{}' but GitHub labels=[{}]",
745                task.status.as_str(),
746                actual
747            ),
748            fix: FixAction::SyncLabels { store_id: task.id },
749        });
750    }
751}
752
753/// Check 6: Stale in_progress task (no tmux session).
754async fn check_stale_in_progress(
755    task: &Task,
756    repo: &str,
757    tmux: &TmuxManager,
758    findings: &mut Vec<Finding>,
759) {
760    let task_id_str = match &task.external_id {
761        Some(id) => id.clone(),
762        None => format!("internal:{}", task.id),
763    };
764    let session = tmux.session_name(repo, &task_id_str);
765    if !tmux.session_exists(&session).await {
766        findings.push(Finding {
767            severity: Severity::Error,
768            task_id: task_label(task),
769            message: format!(
770                "status is 'in_progress' but no tmux session '{}' found",
771                session
772            ),
773            fix: FixAction::ResetToRouted { store_id: task.id },
774        });
775    }
776}
777
778/// Check 7: Orphaned worktrees.
779async fn check_orphaned_worktrees(all_tasks: &[Task], findings: &mut Vec<Finding>) {
780    let worktrees_dir = match crate::home::worktrees_dir() {
781        Ok(d) => d,
782        Err(_) => return,
783    };
784
785    let project_dirs = match std::fs::read_dir(&worktrees_dir) {
786        Ok(d) => d,
787        Err(_) => return,
788    };
789
790    // Build a HashMap for O(1) worktree-path → task lookups instead of O(T) linear scan.
791    let task_by_worktree: std::collections::HashMap<&str, &Task> = all_tasks
792        .iter()
793        .filter(|t| !t.worktree.is_empty())
794        .map(|t| (t.worktree.as_str(), t))
795        .collect();
796
797    for project_entry in project_dirs {
798        let project_entry = match project_entry {
799            Ok(e) => e,
800            Err(e) => {
801                tracing::warn!(err = %e, "error reading worktrees directory entry");
802                continue;
803            }
804        };
805        if !project_entry
806            .file_type()
807            .map(|t| t.is_dir())
808            .unwrap_or(false)
809        {
810            continue;
811        }
812        let branch_dirs = match std::fs::read_dir(project_entry.path()) {
813            Ok(d) => d,
814            Err(_) => continue,
815        };
816        for branch_entry in branch_dirs {
817            let branch_entry = match branch_entry {
818                Ok(e) => e,
819                Err(e) => {
820                    tracing::warn!(err = %e, "error reading project directory entry");
821                    continue;
822                }
823            };
824            if !branch_entry
825                .file_type()
826                .map(|t| t.is_dir())
827                .unwrap_or(false)
828            {
829                continue;
830            }
831            let wt_path = branch_entry.path().to_string_lossy().to_string();
832
833            let owning_task = task_by_worktree.get(wt_path.as_str()).copied();
834
835            match owning_task {
836                Some(task) => {
837                    if task.status == TaskStatus::Done && !task.worktree_cleaned {
838                        // Check if worktree has uncommitted/unpushed work
839                        let has_work = worktree_has_work(Path::new(&wt_path), &task.branch).await;
840                        if has_work {
841                            // Don't auto-clean — there's work to save
842                            findings.push(Finding {
843                                severity: Severity::Warning,
844                                task_id: task_label(task),
845                                message: format!("done but worktree has unsaved work: {}", wt_path),
846                                fix: FixAction::None,
847                            });
848                        } else {
849                            findings.push(Finding {
850                                severity: Severity::Warning,
851                                task_id: task_label(task),
852                                message: format!("done but worktree not cleaned up: {}", wt_path),
853                                fix: FixAction::CleanWorktree { store_id: task.id },
854                            });
855                        }
856                    }
857                }
858                None => {
859                    findings.push(Finding {
860                        severity: Severity::Warning,
861                        task_id: "?".to_string(),
862                        message: format!("orphaned worktree (no task owns it): {}", wt_path),
863                        fix: FixAction::RemoveOrphanedWorktree {
864                            path: wt_path.clone(),
865                        },
866                    });
867                }
868            }
869        }
870    }
871}
872
873/// Check 8: PR merged but task not done (uses pre-fetched pr_states cache).
874fn check_pr_merged_not_done(
875    task: &Task,
876    pr_states: &std::collections::HashMap<u64, (bool, String)>,
877    findings: &mut Vec<Finding>,
878) {
879    let pr_num = match task.pr_number {
880        Some(n) => n as u64,
881        None => return,
882    };
883
884    if let Some((merged, _)) = pr_states.get(&pr_num) {
885        if *merged {
886            findings.push(Finding {
887                severity: Severity::Error,
888                task_id: task_label(task),
889                message: format!(
890                    "PR #{} is merged but task status is '{}'",
891                    pr_num,
892                    task.status.as_str()
893                ),
894                fix: FixAction::MarkDoneFromMergedPr { store_id: task.id },
895            });
896        }
897    }
898}
899
900/// Check 9: Dead review sessions.
901async fn check_dead_review_session(
902    task: &Task,
903    repo: &str,
904    tmux: &TmuxManager,
905    findings: &mut Vec<Finding>,
906) {
907    if !task.review_session_expected {
908        return;
909    }
910    let task_id_str = match &task.external_id {
911        Some(id) => id.clone(),
912        None => format!("internal:{}", task.id),
913    };
914    let review_task_id = format!("{}-review", task_id_str);
915    let session = tmux.session_name(repo, &review_task_id);
916    if !tmux.session_exists(&session).await {
917        findings.push(Finding {
918            severity: Severity::Error,
919            task_id: task_label(task),
920            message: format!(
921                "status is 'in_review' with review_session_expected but no review session '{}' found",
922                session
923            ),
924            fix: FixAction::ResetDeadReview { store_id: task.id },
925        });
926    }
927}
928
929/// Check if a worktree has uncommitted changes or unpushed commits.
930async fn worktree_has_work(wt: &Path, branch: &str) -> bool {
931    if !wt.join(".git").exists() {
932        return false;
933    }
934    let has_dirty = tokio::process::Command::new("git")
935        .args(["status", "--porcelain"])
936        .current_dir(wt)
937        .output()
938        .await
939        .map(|o| !o.stdout.is_empty())
940        .unwrap_or(false);
941    if has_dirty {
942        return true;
943    }
944    if !branch.is_empty() {
945        let has_unpushed = tokio::process::Command::new("git")
946            .args(["log", &format!("origin/{}..HEAD", branch), "--oneline"])
947            .current_dir(wt)
948            .output()
949            .await
950            .map(|o| !o.stdout.is_empty())
951            .unwrap_or(false);
952        if has_unpushed {
953            return true;
954        }
955    }
956    false
957}
958
959/// Attempt automatic fixes for known issues.
960async fn apply_fixes(
961    findings: &[Finding],
962    store: &Arc<TaskStore>,
963    repo: &str,
964    backend: &Arc<dyn ExternalBackend>,
965    gh: &GhHttp,
966    dry_run: bool,
967) -> anyhow::Result<()> {
968    let prefix = if dry_run { "[dry-run] " } else { "" };
969    let mut fixed = 0;
970    let mut skipped = 0;
971
972    for f in findings {
973        match &f.fix {
974            FixAction::None => {
975                skipped += 1;
976                continue;
977            }
978            FixAction::CommitPushCreatePr { store_id } => {
979                let task = store.get(*store_id).await?;
980                let wt = Path::new(&task.worktree);
981                if !wt.exists() {
982                    eprintln!("  skip #{}: worktree no longer exists", f.task_id);
983                    skipped += 1;
984                    continue;
985                }
986
987                if dry_run {
988                    println!(
989                        "  {}would commit + push + create PR + reopen issue + set needs_review for #{}",
990                        prefix, f.task_id
991                    );
992                    fixed += 1;
993                    continue;
994                }
995
996                // Commit all changes
997                let commit_ok = git_cmd(wt, &["add", "-A"]).await
998                    && git_cmd(
999                        wt,
1000                        &[
1001                            "commit",
1002                            "-m",
1003                            &format!("fix: recover orphaned work for #{}", f.task_id),
1004                        ],
1005                    )
1006                    .await;
1007                if !commit_ok {
1008                    eprintln!("  fix failed for #{}: git commit failed", f.task_id);
1009                    skipped += 1;
1010                    continue;
1011                }
1012
1013                // Push
1014                if !git_cmd(
1015                    wt,
1016                    &[
1017                        "-c",
1018                        "lfs.locksverify=false",
1019                        "push",
1020                        "-u",
1021                        "origin",
1022                        &task.branch,
1023                    ],
1024                )
1025                .await
1026                {
1027                    eprintln!("  fix failed for #{}: git push failed", f.task_id);
1028                    skipped += 1;
1029                    continue;
1030                }
1031
1032                // Create PR
1033                match create_pr_for_task(gh, repo, &task).await {
1034                    Ok(pr_num) => {
1035                        let _ = store
1036                            .set_fields(
1037                                *store_id,
1038                                &[("pr_number", serde_json::json!(pr_num as i64))],
1039                            )
1040                            .await;
1041                        // Set needs_review + reopen issue + sync labels
1042                        match reopen_and_set_needs_review(
1043                            store, *store_id, &task, backend, gh, repo,
1044                        )
1045                        .await
1046                        {
1047                            Ok(()) => {
1048                                println!(
1049                                    "  fixed #{}: committed + pushed + created PR #{} + reopened + needs_review",
1050                                    f.task_id, pr_num
1051                                );
1052                                fixed += 1;
1053                            }
1054                            Err(e) => {
1055                                eprintln!(
1056                                    "  fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1057                                    f.task_id, pr_num
1058                                );
1059                                skipped += 1;
1060                            }
1061                        }
1062                    }
1063                    Err(e) => {
1064                        // PR creation failed — it may be a transient server error. Re-check GitHub
1065                        // for an existing PR for the branch and link it if found.
1066                        eprintln!(
1067                            "  PR creation error for #{}: {}. Re-checking for existing PR...",
1068                            f.task_id, e
1069                        );
1070                        match gh.get_pr_number(repo, &task.branch).await {
1071                            Ok(Some(pr_num)) => {
1072                                let _ = store
1073                                    .set_fields(
1074                                        *store_id,
1075                                        &[("pr_number", serde_json::json!(pr_num as i64))],
1076                                    )
1077                                    .await;
1078                                match reopen_and_set_needs_review(
1079                                    store, *store_id, &task, backend, gh, repo,
1080                                )
1081                                .await
1082                                {
1083                                    Ok(()) => {
1084                                        println!(
1085                                            "  fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1086                                            f.task_id, pr_num
1087                                        );
1088                                        fixed += 1;
1089                                    }
1090                                    Err(e2) => {
1091                                        eprintln!(
1092                                            "  fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1093                                            f.task_id, pr_num
1094                                        );
1095                                        skipped += 1;
1096                                    }
1097                                }
1098                            }
1099                            Ok(None) => {
1100                                eprintln!("  fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1101                                skipped += 1;
1102                            }
1103                            Err(err2) => {
1104                                eprintln!("  fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1105                                skipped += 1;
1106                            }
1107                        }
1108                    }
1109                }
1110            }
1111            FixAction::PushCreatePr { store_id } => {
1112                let task = store.get(*store_id).await?;
1113                let wt = Path::new(&task.worktree);
1114                if !wt.exists() {
1115                    eprintln!("  skip #{}: worktree no longer exists", f.task_id);
1116                    skipped += 1;
1117                    continue;
1118                }
1119
1120                if dry_run {
1121                    println!(
1122                        "  {}would push + create PR + reopen issue + set needs_review for #{}",
1123                        prefix, f.task_id
1124                    );
1125                    fixed += 1;
1126                    continue;
1127                }
1128
1129                if !git_cmd(wt, &["push", "-u", "origin", &task.branch]).await {
1130                    eprintln!("  fix failed for #{}: git push failed", f.task_id);
1131                    skipped += 1;
1132                    continue;
1133                }
1134
1135                match create_pr_for_task(gh, repo, &task).await {
1136                    Ok(pr_num) => {
1137                        let _ = store
1138                            .set_fields(
1139                                *store_id,
1140                                &[("pr_number", serde_json::json!(pr_num as i64))],
1141                            )
1142                            .await;
1143                        match reopen_and_set_needs_review(
1144                            store, *store_id, &task, backend, gh, repo,
1145                        )
1146                        .await
1147                        {
1148                            Ok(()) => {
1149                                println!(
1150                                    "  fixed #{}: pushed + created PR #{} + reopened + needs_review",
1151                                    f.task_id, pr_num
1152                                );
1153                                fixed += 1;
1154                            }
1155                            Err(e) => {
1156                                eprintln!(
1157                                    "  fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1158                                    f.task_id, pr_num
1159                                );
1160                                skipped += 1;
1161                            }
1162                        }
1163                    }
1164                    Err(e) => {
1165                        eprintln!(
1166                            "  PR creation error for #{}: {}. Re-checking for existing PR...",
1167                            f.task_id, e
1168                        );
1169                        match gh.get_pr_number(repo, &task.branch).await {
1170                            Ok(Some(pr_num)) => {
1171                                let _ = store
1172                                    .set_fields(
1173                                        *store_id,
1174                                        &[("pr_number", serde_json::json!(pr_num as i64))],
1175                                    )
1176                                    .await;
1177                                match reopen_and_set_needs_review(
1178                                    store, *store_id, &task, backend, gh, repo,
1179                                )
1180                                .await
1181                                {
1182                                    Ok(()) => {
1183                                        println!(
1184                                            "  fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1185                                            f.task_id, pr_num
1186                                        );
1187                                        fixed += 1;
1188                                    }
1189                                    Err(e2) => {
1190                                        eprintln!(
1191                                            "  fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1192                                            f.task_id, pr_num
1193                                        );
1194                                        skipped += 1;
1195                                    }
1196                                }
1197                            }
1198                            Ok(None) => {
1199                                eprintln!("  fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1200                                skipped += 1;
1201                            }
1202                            Err(err2) => {
1203                                eprintln!("  fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1204                                skipped += 1;
1205                            }
1206                        }
1207                    }
1208                }
1209            }
1210            FixAction::CreatePr { store_id } => {
1211                let task = store.get(*store_id).await?;
1212
1213                if dry_run {
1214                    println!(
1215                        "  {}would create PR + reopen issue + set needs_review for #{}",
1216                        prefix, f.task_id
1217                    );
1218                    fixed += 1;
1219                    continue;
1220                }
1221
1222                match create_pr_for_task(gh, repo, &task).await {
1223                    Ok(pr_num) => {
1224                        let _ = store
1225                            .set_fields(
1226                                *store_id,
1227                                &[("pr_number", serde_json::json!(pr_num as i64))],
1228                            )
1229                            .await;
1230                        match reopen_and_set_needs_review(
1231                            store, *store_id, &task, backend, gh, repo,
1232                        )
1233                        .await
1234                        {
1235                            Ok(()) => {
1236                                println!(
1237                                    "  fixed #{}: created PR #{} + reopened + needs_review",
1238                                    f.task_id, pr_num
1239                                );
1240                                fixed += 1;
1241                            }
1242                            Err(e) => {
1243                                eprintln!(
1244                                    "  fix failed for #{}: PR #{} created but status/reopen failed: {e}",
1245                                    f.task_id, pr_num
1246                                );
1247                                skipped += 1;
1248                            }
1249                        }
1250                    }
1251                    Err(e) => {
1252                        eprintln!(
1253                            "  PR creation error for #{}: {}. Re-checking for existing PR...",
1254                            f.task_id, e
1255                        );
1256                        match gh.get_pr_number(repo, &task.branch).await {
1257                            Ok(Some(pr_num)) => {
1258                                let _ = store
1259                                    .set_fields(
1260                                        *store_id,
1261                                        &[("pr_number", serde_json::json!(pr_num as i64))],
1262                                    )
1263                                    .await;
1264                                match reopen_and_set_needs_review(
1265                                    store, *store_id, &task, backend, gh, repo,
1266                                )
1267                                .await
1268                                {
1269                                    Ok(()) => {
1270                                        println!(
1271                                            "  fixed #{}: PR was present on GitHub as #{} (linked) + reopened + needs_review",
1272                                            f.task_id, pr_num
1273                                        );
1274                                        fixed += 1;
1275                                    }
1276                                    Err(e2) => {
1277                                        eprintln!(
1278                                            "  fix failed for #{}: PR #{} linked but status/reopen failed: {e2}",
1279                                            f.task_id, pr_num
1280                                        );
1281                                        skipped += 1;
1282                                    }
1283                                }
1284                            }
1285                            Ok(None) => {
1286                                eprintln!("  fix failed for #{}: PR creation failed and no PR found for branch", f.task_id);
1287                                skipped += 1;
1288                            }
1289                            Err(err2) => {
1290                                eprintln!("  fix failed for #{}: PR creation failed: {}; and PR lookup failed: {}", f.task_id, e, err2);
1291                                skipped += 1;
1292                            }
1293                        }
1294                    }
1295                }
1296            }
1297            FixAction::LinkPrAndReopen {
1298                store_id,
1299                pr_number,
1300            } => {
1301                let task = store.get(*store_id).await?;
1302
1303                if dry_run {
1304                    println!(
1305                        "  {}would link PR #{} + reopen issue + set needs_review for #{}",
1306                        prefix, pr_number, f.task_id
1307                    );
1308                    fixed += 1;
1309                    continue;
1310                }
1311
1312                let _ = store
1313                    .set_fields(
1314                        *store_id,
1315                        &[("pr_number", serde_json::json!(*pr_number as i64))],
1316                    )
1317                    .await;
1318                match reopen_and_set_needs_review(store, *store_id, &task, backend, gh, repo).await
1319                {
1320                    Ok(()) => {
1321                        println!(
1322                            "  fixed #{}: linked PR #{} + reopened + needs_review",
1323                            f.task_id, pr_number
1324                        );
1325                        fixed += 1;
1326                    }
1327                    Err(e) => {
1328                        eprintln!(
1329                            "  fix failed for #{}: PR #{} linked but status/reopen failed: {e}",
1330                            f.task_id, pr_number
1331                        );
1332                        skipped += 1;
1333                    }
1334                }
1335            }
1336            FixAction::LinkPr {
1337                store_id,
1338                pr_number,
1339            } => {
1340                if dry_run {
1341                    println!(
1342                        "  {}would link PR #{} to task #{}",
1343                        prefix, pr_number, f.task_id
1344                    );
1345                    fixed += 1;
1346                    continue;
1347                }
1348
1349                let _ = store
1350                    .set_fields(
1351                        *store_id,
1352                        &[("pr_number", serde_json::json!(*pr_number as i64))],
1353                    )
1354                    .await;
1355                println!("  fixed #{}: linked PR #{}", f.task_id, pr_number);
1356                fixed += 1;
1357            }
1358            FixAction::ReopenIssue { store_id } => {
1359                let task = store.get(*store_id).await?;
1360
1361                if dry_run {
1362                    println!(
1363                        "  {}would reopen GitHub issue + sync labels for #{}",
1364                        prefix, f.task_id
1365                    );
1366                    fixed += 1;
1367                    continue;
1368                }
1369
1370                if let Some(ref ext_id) = task.external_id {
1371                    if let Err(e) = gh.reopen_issue(repo, ext_id).await {
1372                        eprintln!("  fix failed for #{}: reopen failed: {}", f.task_id, e);
1373                        skipped += 1;
1374                        continue;
1375                    }
1376                    let status = task_status_to_backend_status(task.status);
1377                    let _ = backend
1378                        .update_status(&ExternalId(ext_id.clone()), status)
1379                        .await;
1380                    println!("  fixed #{}: reopened issue + synced labels", f.task_id);
1381                    fixed += 1;
1382                } else {
1383                    skipped += 1;
1384                }
1385            }
1386            FixAction::CloseIssue { store_id } => {
1387                let task = store.get(*store_id).await?;
1388
1389                if dry_run {
1390                    println!("  {}would close GitHub issue #{}", prefix, f.task_id);
1391                    fixed += 1;
1392                    continue;
1393                }
1394
1395                if let Some(ref ext_id) = task.external_id {
1396                    let status = crate::backends::Status::Done;
1397                    if let Err(e) = backend
1398                        .update_status(&ExternalId(ext_id.clone()), status)
1399                        .await
1400                    {
1401                        eprintln!("  fix failed for #{}: {}", f.task_id, e);
1402                        skipped += 1;
1403                    } else {
1404                        println!("  fixed #{}: closed GitHub issue", f.task_id);
1405                        fixed += 1;
1406                    }
1407                } else {
1408                    skipped += 1;
1409                }
1410            }
1411            FixAction::SyncLabels { store_id } => {
1412                let task = store.get(*store_id).await?;
1413
1414                if dry_run {
1415                    println!(
1416                        "  {}would sync labels to status:{} for #{}",
1417                        prefix,
1418                        task.status.as_str(),
1419                        f.task_id
1420                    );
1421                    fixed += 1;
1422                    continue;
1423                }
1424
1425                if let Some(ref ext_id) = task.external_id {
1426                    let status = task_status_to_backend_status(task.status);
1427                    if let Err(e) = backend
1428                        .update_status(&ExternalId(ext_id.clone()), status)
1429                        .await
1430                    {
1431                        eprintln!("  fix failed for #{}: {}", f.task_id, e);
1432                        skipped += 1;
1433                    } else {
1434                        println!("  fixed #{}: synced labels", f.task_id);
1435                        fixed += 1;
1436                    }
1437                } else {
1438                    skipped += 1;
1439                }
1440            }
1441            FixAction::ResetToRouted { store_id } => {
1442                if dry_run {
1443                    println!(
1444                        "  {}would reset #{} to 'routed' (re-dispatch on next tick)",
1445                        prefix, f.task_id
1446                    );
1447                    fixed += 1;
1448                    continue;
1449                }
1450
1451                if let Err(e) = store.update_status(*store_id, TaskStatus::Routed).await {
1452                    eprintln!("  fix failed for #{}: {}", f.task_id, e);
1453                    skipped += 1;
1454                    continue;
1455                }
1456
1457                // Sync labels if external
1458                let task = store.get(*store_id).await?;
1459                if let Some(ref ext_id) = task.external_id {
1460                    let _ = backend
1461                        .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Routed)
1462                        .await;
1463                }
1464                println!("  fixed #{}: reset to routed", f.task_id);
1465                fixed += 1;
1466            }
1467            FixAction::CleanWorktree { store_id } => {
1468                let task = store.get(*store_id).await?;
1469
1470                if dry_run {
1471                    println!(
1472                        "  {}would clean worktree + mark cleaned for #{}",
1473                        prefix, f.task_id
1474                    );
1475                    fixed += 1;
1476                    continue;
1477                }
1478
1479                let wt = Path::new(&task.worktree);
1480                if wt.exists() {
1481                    match resolve_repo_root(repo).await {
1482                        Ok(root) => {
1483                            let branch = if task.branch.is_empty() {
1484                                None
1485                            } else {
1486                                Some(task.branch.as_str())
1487                            };
1488                            let removed = remove_worktree_and_branch(
1489                                &f.task_id,
1490                                wt,
1491                                branch,
1492                                Path::new(&root),
1493                                task.pr_number.is_some(), // keep remote branch if PR exists
1494                            )
1495                            .await;
1496                            if removed {
1497                                let _ = store.mark_cleaned(*store_id).await;
1498                                println!("  fixed #{}: cleaned worktree", f.task_id);
1499                                fixed += 1;
1500                            } else {
1501                                eprintln!(
1502                                    "  fix failed for #{}: worktree removal failed",
1503                                    f.task_id
1504                                );
1505                                skipped += 1;
1506                            }
1507                        }
1508                        Err(e) => {
1509                            eprintln!(
1510                                "  fix failed for #{}: cannot resolve repo root: {}",
1511                                f.task_id, e
1512                            );
1513                            skipped += 1;
1514                        }
1515                    }
1516                } else {
1517                    // Directory already gone — just mark cleaned
1518                    let _ = store.mark_cleaned(*store_id).await;
1519                    println!(
1520                        "  fixed #{}: marked worktree cleaned (already gone)",
1521                        f.task_id
1522                    );
1523                    fixed += 1;
1524                }
1525            }
1526            FixAction::RemoveOrphanedWorktree { path } => {
1527                if dry_run {
1528                    println!("  {}would remove orphaned worktree: {}", prefix, path);
1529                    fixed += 1;
1530                    continue;
1531                }
1532
1533                let removed = cleanup_orphaned_worktree(&f.task_id, Path::new(path)).await;
1534                if removed {
1535                    println!("  fixed: removed orphaned worktree {}", path);
1536                    fixed += 1;
1537                } else {
1538                    eprintln!("  fix failed: could not remove orphaned worktree {}", path);
1539                    skipped += 1;
1540                }
1541            }
1542            FixAction::MarkDoneFromMergedPr { store_id } => {
1543                let task = store.get(*store_id).await?;
1544
1545                if dry_run {
1546                    println!(
1547                        "  {}would mark #{} done + close issue + clean worktree",
1548                        prefix, f.task_id
1549                    );
1550                    fixed += 1;
1551                    continue;
1552                }
1553
1554                // Set status to done
1555                if let Err(e) = store.update_status(*store_id, TaskStatus::Done).await {
1556                    eprintln!("  fix failed for #{}: {}", f.task_id, e);
1557                    skipped += 1;
1558                    continue;
1559                }
1560
1561                // Close GitHub issue + sync labels
1562                if let Some(ref ext_id) = task.external_id {
1563                    let _ = backend
1564                        .update_status(&ExternalId(ext_id.clone()), crate::backends::Status::Done)
1565                        .await;
1566                }
1567
1568                // Clean worktree if present
1569                if !task.worktree.is_empty() && !task.worktree_cleaned {
1570                    let wt = Path::new(&task.worktree);
1571                    if wt.exists() {
1572                        if let Ok(root) = resolve_repo_root(repo).await {
1573                            let branch = if task.branch.is_empty() {
1574                                None
1575                            } else {
1576                                Some(task.branch.as_str())
1577                            };
1578                            let removed = remove_worktree_and_branch(
1579                                &f.task_id,
1580                                wt,
1581                                branch,
1582                                Path::new(&root),
1583                                true, // keep remote — PR is merged
1584                            )
1585                            .await;
1586                            if removed {
1587                                let _ = store.mark_cleaned(*store_id).await;
1588                            }
1589                        }
1590                    } else {
1591                        let _ = store.mark_cleaned(*store_id).await;
1592                    }
1593                }
1594
1595                println!("  fixed #{}: marked done + closed issue", f.task_id);
1596                fixed += 1;
1597            }
1598            FixAction::ResetDeadReview { store_id } => {
1599                if dry_run {
1600                    println!(
1601                        "  {}would reset #{} review_session_expected=0 + set needs_review",
1602                        prefix, f.task_id
1603                    );
1604                    fixed += 1;
1605                    continue;
1606                }
1607
1608                // Reset review_session_expected and set status to needs_review
1609                let _ = store
1610                    .set_fields(
1611                        *store_id,
1612                        &[("review_session_expected", serde_json::json!(false))],
1613                    )
1614                    .await;
1615                if let Err(e) = store
1616                    .update_status(*store_id, TaskStatus::NeedsReview)
1617                    .await
1618                {
1619                    eprintln!("  fix failed for #{}: {}", f.task_id, e);
1620                    skipped += 1;
1621                    continue;
1622                }
1623
1624                // Sync labels
1625                let task = store.get(*store_id).await?;
1626                if let Some(ref ext_id) = task.external_id {
1627                    let _ = backend
1628                        .update_status(
1629                            &ExternalId(ext_id.clone()),
1630                            crate::backends::Status::NeedsReview,
1631                        )
1632                        .await;
1633                }
1634                println!(
1635                    "  fixed #{}: reset review session + set needs_review",
1636                    f.task_id
1637                );
1638                fixed += 1;
1639            }
1640        }
1641    }
1642
1643    let action = if dry_run { "would fix" } else { "fixed" };
1644    println!("\n{} {}, skipped {}.", action, fixed, skipped);
1645
1646    if !dry_run && skipped > 0 {
1647        println!("Use `orch task reopen <id>` to manually recover skipped tasks.");
1648    }
1649
1650    Ok(())
1651}
1652
1653/// Helper: reopen issue, set status to needs_review, sync labels.
1654///
1655/// Returns an error if the SQLite status update or the GitHub issue reopen
1656/// fails. Backend label sync failures are logged as warnings but do not cause
1657/// the function to fail, since they are secondary/cosmetic.
1658async fn reopen_and_set_needs_review(
1659    store: &Arc<TaskStore>,
1660    store_id: i64,
1661    task: &Task,
1662    backend: &Arc<dyn ExternalBackend>,
1663    gh: &GhHttp,
1664    repo: &str,
1665) -> anyhow::Result<()> {
1666    // Update SQLite status — hard failure: without this the task remains in
1667    // the wrong state and the next doctor run will attempt the same fix again.
1668    store
1669        .update_status(store_id, TaskStatus::NeedsReview)
1670        .await
1671        .context("failed to update task status to NeedsReview in database")?;
1672
1673    // Reopen issue + sync labels for external tasks
1674    if let Some(ref ext_id) = task.external_id {
1675        // Hard failure: if the issue stays closed the engine will never pick it up.
1676        gh.reopen_issue(repo, ext_id)
1677            .await
1678            .with_context(|| format!("failed to reopen GitHub issue {ext_id}"))?;
1679
1680        // Soft failure: label sync is cosmetic; log a warning but don't block.
1681        if let Err(e) = backend
1682            .update_status(
1683                &ExternalId(ext_id.clone()),
1684                crate::backends::Status::NeedsReview,
1685            )
1686            .await
1687        {
1688            eprintln!(
1689                "  warning: backend label sync failed for #{}: {e}",
1690                task_label(task)
1691            );
1692        }
1693    }
1694
1695    Ok(())
1696}
1697
1698/// Create a PR for a task using `gh` CLI (more reliable for auth than API).
1699async fn create_pr_for_task(gh: &GhHttp, repo: &str, task: &Task) -> anyhow::Result<u64> {
1700    let title = format!("fix: recover work for #{}", task_label(task));
1701    let body = format!(
1702        "Recovered by `orch service doctor --fix`.\n\nOriginal task: #{}",
1703        task_label(task)
1704    );
1705    let pr_url = gh
1706        .create_pr(repo, &title, &body, &task.branch, "main")
1707        .await?;
1708    // Extract PR number from URL (e.g., ".../pull/123")
1709    let pr_num = pr_url
1710        .rsplit('/')
1711        .next()
1712        .and_then(|s| s.parse::<u64>().ok())
1713        .context("could not parse PR number from URL")?;
1714    Ok(pr_num)
1715}
1716
1717/// Run a git command in a worktree directory, returning success/failure.
1718async fn git_cmd(wt: &Path, args: &[&str]) -> bool {
1719    tokio::process::Command::new("git")
1720        .args(args)
1721        .current_dir(wt)
1722        .output()
1723        .await
1724        .map(|o| o.status.success())
1725        .unwrap_or(false)
1726}
1727
1728fn task_label(task: &Task) -> String {
1729    match &task.external_id {
1730        Some(id) => id.clone(),
1731        None => format!("internal:{}", task.id),
1732    }
1733}
1734
1735fn task_status_to_backend_status(status: TaskStatus) -> crate::backends::Status {
1736    match status {
1737        TaskStatus::New => crate::backends::Status::New,
1738        TaskStatus::Routed => crate::backends::Status::Routed,
1739        TaskStatus::InProgress => crate::backends::Status::InProgress,
1740        TaskStatus::Done => crate::backends::Status::Done,
1741        TaskStatus::Blocked => crate::backends::Status::Blocked,
1742        TaskStatus::InReview => crate::backends::Status::InReview,
1743        TaskStatus::NeedsReview => crate::backends::Status::NeedsReview,
1744        TaskStatus::Waiting => crate::backends::Status::Waiting,
1745    }
1746}
1747
1748fn collect_valid_external_issue_numbers<'a>(
1749    tasks: impl IntoIterator<Item = &'a Task>,
1750    repo: &str,
1751    caller_context: &str,
1752) -> Vec<u64> {
1753    let mut issue_numbers = Vec::new();
1754
1755    for task in tasks {
1756        let Some(external_id) = task.external_id.as_deref() else {
1757            continue;
1758        };
1759
1760        match external_id.parse::<u64>() {
1761            Ok(0) => {
1762                tracing::warn!(
1763                    repo,
1764                    caller_context,
1765                    store_id = task.id,
1766                    external_id,
1767                    "dropping invalid external_id before batch issue-state query"
1768                );
1769            }
1770            Ok(issue_number) => issue_numbers.push(issue_number),
1771            Err(error) => {
1772                tracing::warn!(
1773                    repo,
1774                    caller_context,
1775                    store_id = task.id,
1776                    external_id,
1777                    error = %error,
1778                    "skipping non-numeric external_id in batch issue-state query"
1779                );
1780            }
1781        }
1782    }
1783
1784    issue_numbers.sort_unstable();
1785    issue_numbers.dedup();
1786    issue_numbers
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791    use super::collect_valid_external_issue_numbers;
1792    use crate::store::{Task, TaskStatus};
1793
1794    fn task(id: i64, external_id: Option<&str>, origin: &str) -> Task {
1795        Task {
1796            id,
1797            external_id: external_id.map(ToOwned::to_owned),
1798            repo: "owner/repo".to_string(),
1799            origin: origin.to_string(),
1800            title: String::new(),
1801            body: String::new(),
1802            status: TaskStatus::New,
1803            source: String::new(),
1804            source_id: String::new(),
1805            author: String::new(),
1806            url: String::new(),
1807            labels: Vec::new(),
1808            agent: None,
1809            model: None,
1810            complexity: String::new(),
1811            estimate: 0,
1812            route_reason: String::new(),
1813            agent_profile: String::new(),
1814            selected_skills: String::new(),
1815            route_attempts: 0,
1816            attempts: 0,
1817            branch: String::new(),
1818            worktree: String::new(),
1819            worktree_cleaned: false,
1820            summary: String::new(),
1821            last_error: String::new(),
1822            parent_id: None,
1823            block_reason: None,
1824            pr_number: None,
1825            pr_review_context: String::new(),
1826            last_review_ts: String::new(),
1827            review_ts_map: String::new(),
1828            last_comment_review_ts: String::new(),
1829            merge_conflict_retries: 0,
1830            ci_merge_failures: 0,
1831            pr_create_failures: 0,
1832            push_failures: 0,
1833            network_retries: 0,
1834            review_agent_failures: 0,
1835            review_cycles: 0,
1836            review_invocations: 0,
1837            review_session_expected: false,
1838            needs_review_refires: 0,
1839            input_tokens: 0,
1840            output_tokens: 0,
1841            input_cost_usd: 0.0,
1842            output_cost_usd: 0.0,
1843            total_cost_usd: 0.0,
1844            model_reroute_chain: String::new(),
1845            limit_reroute_chain: String::new(),
1846            memory: Vec::new(),
1847            delegations: Vec::new(),
1848            auto_unblock_count: 0,
1849            auto_unblock_last_at: String::new(),
1850            auto_unblock_last_reason: String::new(),
1851            ci_recovery_count: 0,
1852            no_code_reroutes: 0,
1853            no_code_last_agent: String::new(),
1854            created_at: String::new(),
1855            updated_at: String::new(),
1856        }
1857    }
1858
1859    #[test]
1860    fn collect_valid_external_issue_numbers_filters_zero_and_non_numeric_ids() {
1861        let tasks = [
1862            task(1, Some("42"), "github"),
1863            task(2, Some("0"), "github"),
1864            task(3, Some("not-a-number"), "github"),
1865            task(4, Some("42"), "github"),
1866        ];
1867
1868        let issue_numbers =
1869            collect_valid_external_issue_numbers(tasks.iter(), "owner/repo", "test");
1870
1871        assert_eq!(issue_numbers, vec![42]);
1872    }
1873}