Skip to main content

orch/engine/
cleanup.rs

1//! Worktree cleanup and merged-PR detection.
2//!
3//! Contains the post-merge cleanup pipeline: removing git worktrees,
4//! deleting local/remote branches, pulling main, and detecting
5//! already-merged PRs so their tasks can be marked done.
6
7use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
8use crate::cmd::CommandErrorContext;
9use crate::engine::tasks::TaskManager;
10use crate::store;
11use crate::store::store_log_activity;
12use crate::store::TaskStatus;
13use crate::store::TaskStore;
14use dashmap::{DashMap, DashSet};
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::sync::LazyLock;
18use std::time::{Duration, Instant};
19use tokio::process::Command;
20use tokio::time::sleep;
21use tokio::time::timeout;
22
23use super::sync::ReviewTaskSnapshot;
24
25/// Reconciliation calls can be slow on repos with many issues or during GitHub instability.
26/// Cleanup runs in a background task (tokio::spawn) so it does not block the main tick loop.
27/// 30 s gives enough headroom for paginated list_all_tasks calls while still bounding the
28/// overall background-task duration.
29const RECONCILIATION_LIST_TIMEOUT: Duration = Duration::from_secs(30);
30/// Closed-issue reconciliation is a best-effort cleanup fallback, not a per-sync requirement.
31/// Run it on a slower cadence so repeated GitHub slowness does not consume 30 s every sync.
32const RECONCILIATION_INTERVAL: Duration = Duration::from_secs(15 * 60);
33
34/// Per-repo in-flight guard for closed-issue reconciliation/cleanup.
35/// Prevents overlapping cleanup runs from piling up across sync cycles.
36static CLEANUP_IN_FLIGHT: LazyLock<DashSet<String>> = LazyLock::new(DashSet::new);
37/// Per-repo reconciliation cadence tracker.
38static RECONCILIATION_LAST_RUN: LazyLock<DashMap<String, Instant>> = LazyLock::new(DashMap::new);
39
40struct CleanupInFlightGuard {
41    repo: String,
42}
43
44impl CleanupInFlightGuard {
45    fn try_acquire(repo: &str) -> Option<Self> {
46        if CLEANUP_IN_FLIGHT.insert(repo.to_string()) {
47            Some(Self {
48                repo: repo.to_string(),
49            })
50        } else {
51            None
52        }
53    }
54}
55
56impl Drop for CleanupInFlightGuard {
57    fn drop(&mut self) {
58        CLEANUP_IN_FLIGHT.remove(&self.repo);
59    }
60}
61
62fn should_run_reconciliation(repo: &str) -> bool {
63    let now = Instant::now();
64    if let Some(mut last_run) = RECONCILIATION_LAST_RUN.get_mut(repo) {
65        if last_run.elapsed() < RECONCILIATION_INTERVAL {
66            return false;
67        }
68        *last_run = now;
69        true
70    } else {
71        RECONCILIATION_LAST_RUN.insert(repo.to_string(), now);
72        true
73    }
74}
75
76/// Options controlling the worktree janitor.
77#[derive(Debug, Clone)]
78pub struct JanitorOptions {
79    /// Minimum age (in hours) of the worktree directory before it is eligible for removal.
80    ///
81    /// A worktree whose filesystem mtime is more recent than this threshold is skipped
82    /// even if the task is already in a terminal state. This provides a safety buffer
83    /// against races (e.g. review agent still writing when the janitor runs).
84    ///
85    /// Default: 24.  Configurable via `workflow.worktree_janitor_ttl_hours` in config.
86    pub ttl_hours: u64,
87
88    /// When `true`, log what would be done but skip all destructive operations.
89    ///
90    /// Configurable via `workflow.worktree_janitor_dry_run: true` in config.
91    pub dry_run: bool,
92}
93
94impl Default for JanitorOptions {
95    fn default() -> Self {
96        Self {
97            ttl_hours: 24,
98            dry_run: false,
99        }
100    }
101}
102
103impl JanitorOptions {
104    /// Read options from the global config, falling back to defaults.
105    pub fn from_config() -> Self {
106        let ttl_hours = crate::config::get("workflow.worktree_janitor_ttl_hours")
107            .ok()
108            .and_then(|v| v.parse::<u64>().ok())
109            .unwrap_or(24);
110
111        let dry_run = crate::config::get("workflow.worktree_janitor_dry_run")
112            .ok()
113            .map(|v| v == "true")
114            .unwrap_or(false);
115
116        Self { ttl_hours, dry_run }
117    }
118}
119
120/// Cleanup worktrees for completed tasks.
121///
122/// Targets tasks that are finished: status:done OR closed on GitHub
123/// (e.g. when a PR merge auto-closes the issue via "Fixes #N").
124///
125/// Note: This is a fallback. Primary cleanup happens inline in
126/// `auto_merge_pr` via `cleanup_task_worktree`. This catches edge cases
127/// where the inline cleanup missed (e.g., manual merges, auto-closed issues).
128pub(crate) async fn cleanup_done_worktrees(
129    backend: &Arc<dyn ExternalBackend>,
130    repo: &str,
131    task_manager: &Arc<TaskManager>,
132    store: &Arc<TaskStore>,
133) -> anyhow::Result<()> {
134    let opts = JanitorOptions::from_config();
135    cleanup_done_worktrees_with_opts(backend, repo, task_manager, store, &opts).await
136}
137
138async fn reconcile_closed_tasks(
139    task_ids: &mut Vec<String>,
140    all_tasks: &[ExternalTask],
141    task_manager: &TaskManager,
142) {
143    let done_set: std::collections::HashSet<String> = task_ids.iter().cloned().collect();
144    let closed_not_in_store: Vec<_> = all_tasks
145        .iter()
146        .filter(|t| t.state == "closed" && !done_set.contains(&t.id.0))
147        .collect();
148
149    // Split: issues that already have status:done label just need worktree
150    // cleanup (no API call needed), vs those needing label reconciliation.
151    let mut already_labeled: Vec<String> = Vec::new();
152    let mut needs_reconcile: Vec<&ExternalTask> = Vec::new();
153    for task in &closed_not_in_store {
154        if task.labels.iter().any(|l| l == "status:done") {
155            already_labeled.push(task.id.0.clone());
156        } else {
157            needs_reconcile.push(task);
158        }
159    }
160
161    // Add already-labeled issues directly (no API call).
162    if !already_labeled.is_empty() {
163        tracing::debug!(
164            count = already_labeled.len(),
165            "closed issues already labeled status:done — adding to cleanup set"
166        );
167        task_ids.extend(already_labeled);
168    }
169
170    // Reconcile issues missing the done label — cap to avoid rate-limit
171    // exhaustion. Remaining issues will be picked up in subsequent cycles.
172    const MAX_RECONCILE_PER_CYCLE: usize = 10;
173    if needs_reconcile.len() > MAX_RECONCILE_PER_CYCLE {
174        tracing::info!(
175            total = needs_reconcile.len(),
176            batch = MAX_RECONCILE_PER_CYCLE,
177            "capping closed-issue reconciliation to avoid rate-limit exhaustion"
178        );
179    }
180    let mut futs = Vec::new();
181    for task in needs_reconcile.iter().take(MAX_RECONCILE_PER_CYCLE) {
182        let id = task.id.clone();
183        let task_id_str = task.id.0.clone();
184        let labels = task.labels.clone();
185        futs.push(async move {
186            tracing::info!(
187                task_id = task_id_str,
188                labels = ?labels,
189                "closed issue with stale status label — reconciling to done and cleaning up"
190            );
191            match task_manager.update_task_status(&id, Status::Done).await {
192                Ok(()) => Some(task_id_str),
193                Err(e) => {
194                    tracing::warn!(
195                        task_id = task_id_str,
196                        err = %e,
197                        "failed to reconcile closed issue status to done"
198                    );
199                    None
200                }
201            }
202        });
203    }
204    let results = futures::future::join_all(futs).await;
205    task_ids.extend(results.into_iter().flatten());
206}
207
208/// Cleanup worktrees for completed tasks with explicit options.
209///
210/// Separated from `cleanup_done_worktrees` so that integration tests can
211/// inject specific options (e.g. `ttl_hours: 0`, `dry_run: true`) without
212/// touching the global config.
213pub(crate) async fn cleanup_done_worktrees_with_opts(
214    backend: &Arc<dyn ExternalBackend>,
215    repo: &str,
216    task_manager: &Arc<TaskManager>,
217    store: &Arc<TaskStore>,
218    opts: &JanitorOptions,
219) -> anyhow::Result<()> {
220    let Some(_guard) = CleanupInFlightGuard::try_acquire(repo) else {
221        tracing::debug!(
222            repo = %repo,
223            "cleanup already in progress for repo — skipping overlapping run"
224        );
225        return Ok(());
226    };
227
228    // Read done tasks from the store first; fall back to backend before first sync.
229    let done_tasks = task_manager.list_all_by_status(Status::Done).await?;
230    tracing::debug!(count = done_tasks.len(), "checking done tasks for cleanup");
231
232    // Collect task IDs from external done tasks.
233    let mut task_ids: Vec<String> = done_tasks.iter().map(|t| t.id.0.clone()).collect();
234
235    // Also include closed-but-not-done external tasks.
236    // When a PR merge auto-closes a GitHub issue (via "Fixes #N"), the issue
237    // state becomes "closed" but orch never updates the status label to "done".
238    // These orphaned worktrees accumulate unless we catch them here.
239    // Note: This still queries the backend because GitHub `state` (open/closed)
240    // is backend-specific and not tracked in the store.
241    //
242    // Use list_reconciliation_candidates() (closed issues only, bounded recent window) directly.
243    // list_all_tasks() fetches every issue ever created (state=all, all pages) and
244    // consistently times out on repos with many issues — it is not appropriate for
245    // a cleanup fallback path.
246    if should_run_reconciliation(repo) {
247        match timeout(
248            RECONCILIATION_LIST_TIMEOUT,
249            backend.list_reconciliation_candidates(),
250        )
251        .await
252        {
253            Ok(Ok(candidates)) if !candidates.is_empty() => {
254                tracing::debug!(
255                    count = candidates.len(),
256                    "reconciling closed issues from candidates"
257                );
258                reconcile_closed_tasks(&mut task_ids, &candidates, task_manager).await;
259            }
260            Ok(Ok(_)) => {
261                tracing::debug!("no reconciliation candidates found");
262            }
263            Ok(Err(e)) => {
264                tracing::warn!(err = %e, "failed to list reconciliation candidates");
265            }
266            Err(_) => {
267                tracing::warn!(
268                    timeout_secs = RECONCILIATION_LIST_TIMEOUT.as_secs(),
269                    retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
270                    "timed out listing reconciliation candidates"
271                );
272            }
273        }
274    } else {
275        tracing::debug!(
276            retry_after_secs = RECONCILIATION_INTERVAL.as_secs(),
277            "skipping closed-issue reconciliation until interval elapses"
278        );
279    }
280
281    // Also include internal done tasks.
282    if let Ok(internal_done) = task_manager.list_internal_by_status(TaskStatus::Done).await {
283        task_ids.extend(internal_done.iter().map(|t| t.id.0.clone()));
284    }
285
286    // Also include internal blocked tasks (terminal state, worktree is useless).
287    if let Ok(internal_blocked) = task_manager
288        .list_internal_by_status(TaskStatus::Blocked)
289        .await
290    {
291        task_ids.extend(internal_blocked.iter().map(|t| t.id.0.clone()));
292    }
293
294    tracing::debug!(
295        count = task_ids.len(),
296        "checking all terminal tasks for cleanup"
297    );
298
299    // Prefetch all cleaned external IDs in one query instead of N+1 per-task lookups.
300    // NOTE: This is now primarily a hint to skip processing, NOT a correctness
301    // guarantee — tasks with external_id NOT in the store at all (or mismatched
302    // repo) will still be cleaned by iterating all worktree directories.
303    let already_cleaned = store.cleaned_external_ids(repo).await.unwrap_or_default();
304
305    let mut cleaned_any = false;
306    for task_id in &task_ids {
307        // Skip if already cleaned (checked via prefetched set).
308        if already_cleaned.contains(task_id) {
309            continue;
310        }
311
312        match cleanup_task_worktree_with_opts(task_id, repo, store, opts).await {
313            Ok(true) => cleaned_any = true,
314            Ok(false) => {
315                // Skipped (TTL guard, tmux guard, or nothing to remove) — do not
316                // pull main; we did not change the repo state.
317            }
318            Err(e) => {
319                tracing::warn!(task_id, err = %e, "worktree cleanup failed for task");
320            }
321        }
322    }
323
324    // ── Orphaned worktree scanner ─────────────────────────────────────────────
325    //
326    // Even if every task in our list was already cleaned (or had no worktree),
327    // there may be worktree directories on disk that are not owned by any task.
328    // This happens when a task was ingested into the store but its worktree_cleaned
329    // flag was never set — e.g. because the task was never in `list_all_by_status`
330    // (unknown repo / missing from store at cleanup time), or the cleanup skipped
331    // due to TTL guard and never retried.
332    //
333    // We scan all worktree directories in this repo's worktrees subdirectory and
334    // remove any that are not referenced by an existing task. This catches the
335    // "done but worktree not cleaned" case that `orch service doctor` reports.
336    let worktrees_base = match crate::home::worktrees_dir() {
337        Ok(base) => Some(base),
338        Err(e) => {
339            tracing::debug!(err = %e, "worktrees_dir() unavailable — skipping orphan scan");
340            None
341        }
342    };
343
344    if let Some(base) = worktrees_base {
345        let project = repo
346            .rsplit('/')
347            .next()
348            .unwrap_or(repo)
349            .trim_end_matches(".git");
350        let repo_worktrees = base.join(project);
351
352        // Build set of all known worktree paths for this repo (all statuses).
353        let mut combined: std::collections::HashSet<String> = std::collections::HashSet::new();
354        if let Ok(tasks) = store.list_all(repo).await {
355            for t in tasks {
356                if !t.worktree.is_empty() {
357                    combined.insert(t.worktree);
358                }
359            }
360        }
361
362        // Also include active worktrees (in case task is in-memory but has a dir).
363        if let Ok(project_dir) = resolve_project_dir_for_repo(repo) {
364            if let Ok(active_wts) =
365                crate::engine::runner::worktree::list_project_worktrees(&project_dir).await
366            {
367                for wt in active_wts {
368                    combined.insert(wt.to_string_lossy().to_string());
369                }
370            }
371        }
372
373        // Scan all branch directories and remove orphaned ones.
374        if let Ok(mut entries) = tokio::fs::read_dir(&repo_worktrees).await {
375            while let Ok(Some(entry)) = entries.next_entry().await {
376                let entry_meta = match entry.metadata().await {
377                    Ok(m) => m,
378                    Err(_) => continue,
379                };
380                if !entry_meta.is_dir() {
381                    continue;
382                }
383                let wt_path = entry.path().to_string_lossy().to_string();
384                if combined.contains(&wt_path) {
385                    continue;
386                }
387                tracing::info!(worktree = %wt_path, "found orphaned worktree — attempting cleanup");
388                match resolve_repo_root_for_orphaned_worktree(&entry.path()).await {
389                    Ok(repo_root) => {
390                        let removed = remove_worktree_and_branch(
391                            "orphan-scan",
392                            &entry.path(),
393                            None,
394                            std::path::Path::new(&repo_root),
395                            false,
396                        )
397                        .await;
398                        if removed {
399                            tracing::info!(worktree = %wt_path, "orphaned worktree removed");
400                        } else {
401                            tracing::warn!(worktree = %wt_path, "orphaned worktree could not be removed");
402                        }
403                    }
404                    Err(e) => {
405                        tracing::warn!(worktree = %wt_path, err = %e, "could not resolve repo root for orphaned worktree");
406                    }
407                }
408            }
409        }
410    }
411
412    // Prune stale worktree metadata and pull main after cleanup.
413    if cleaned_any && !opts.dry_run {
414        if let Ok(repo_root) = resolve_repo_root(repo).await {
415            const GIT_TIMEOUT: Duration = Duration::from_secs(60);
416
417            // Prune stale .git/worktrees/ entries whose directories no longer exist.
418            let prune_result = timeout(
419                GIT_TIMEOUT,
420                Command::new("git")
421                    .args(["-C", &repo_root, "worktree", "prune"])
422                    .output_with_context(),
423            )
424            .await;
425            match prune_result {
426                Ok(Ok(output)) if output.status.success() => {
427                    tracing::debug!(%repo, "pruned stale worktree metadata");
428                }
429                Ok(Ok(output)) => {
430                    let stderr = String::from_utf8_lossy(&output.stderr);
431                    tracing::warn!(%repo, err = %stderr, "git worktree prune failed");
432                }
433                Ok(Err(e)) => {
434                    tracing::warn!(%repo, err = %e, "git worktree prune failed");
435                }
436                Err(_) => {
437                    tracing::warn!(%repo, "git worktree prune timed out after 60s");
438                }
439            }
440
441            // Fetch instead of pull: refresh `origin/*` refs without touching
442            // the operator's checked-out branch or working tree. `pull` would
443            // operate on whatever HEAD happens to be (often a feature branch,
444            // not main) and would silently no-op on divergence.
445            let fetch_result = timeout(
446                GIT_TIMEOUT,
447                Command::new("git")
448                    .args(["-C", &repo_root, "fetch", "--prune", "origin"])
449                    .output_with_context(),
450            )
451            .await;
452            match fetch_result {
453                Ok(Ok(output)) if output.status.success() => {
454                    tracing::info!(%repo, "fetched origin after worktree cleanup");
455                }
456                Ok(Ok(output)) => {
457                    let stderr = String::from_utf8_lossy(&output.stderr);
458                    tracing::debug!(%repo, err = %stderr, "git fetch skipped after cleanup");
459                }
460                Ok(Err(e)) => {
461                    tracing::debug!(%repo, err = %e, "git fetch failed after cleanup");
462                }
463                Err(_) => {
464                    tracing::debug!(%repo, "git fetch timed out after 60s");
465                }
466            }
467        }
468    }
469
470    Ok(())
471}
472
473/// Cleanup a single task's worktree and branches.
474///
475/// Removes the git worktree, deletes local + remote branches,
476/// fetches origin to refresh remote tracking refs, and marks the task as cleaned.
477///
478/// This function is used for inline post-merge cleanup (called from
479/// auto-merge flows) and must attempt immediate removal — it constructs
480/// janitor options with `ttl_hours = 0` so the janitor age guard does not
481/// postpone removing freshly-created worktrees.
482pub(crate) async fn cleanup_task_worktree(
483    task_id: &str,
484    repo: &str,
485    store: &Arc<TaskStore>,
486) -> anyhow::Result<()> {
487    let opts = JanitorOptions {
488        ttl_hours: 0,
489        ..Default::default()
490    };
491    let cleaned = cleanup_task_worktree_with_opts(task_id, repo, store, &opts).await?;
492
493    // Fetch origin once after inline cleanup (post-merge single-task path).
494    // Only fetch if cleanup actually happened (not skipped due to active session).
495    // Fetch (not pull) so the operator's checked-out branch and working tree
496    // are never touched — see the matching comment in cleanup_orphaned_worktrees.
497    if cleaned {
498        if let Ok(repo_root) = resolve_repo_root(repo).await {
499            const GIT_TIMEOUT: Duration = Duration::from_secs(60);
500            let fetch_result = timeout(
501                GIT_TIMEOUT,
502                Command::new("git")
503                    .args(["-C", &repo_root, "fetch", "--prune", "origin"])
504                    .output_with_context(),
505            )
506            .await;
507            match fetch_result {
508                Ok(Ok(output)) if output.status.success() => {
509                    tracing::debug!(%repo, "fetched origin after task cleanup");
510                }
511                Ok(Ok(output)) => {
512                    let stderr = String::from_utf8_lossy(&output.stderr);
513                    tracing::debug!(%repo, err = %stderr, "git fetch skipped after task cleanup");
514                }
515                Ok(Err(e)) => {
516                    tracing::debug!(%repo, err = %e, "git fetch failed after task cleanup");
517                }
518                Err(_) => {
519                    tracing::debug!(%repo, "git fetch timed out after 60s");
520                }
521            }
522        }
523    }
524
525    Ok(())
526}
527
528/// Cleanup a single task's worktree and branches with explicit janitor options.
529///
530/// Returns `Ok(true)` when cleanup was actually performed and the task was
531/// marked as cleaned.  Returns `Ok(false)` when cleanup was intentionally
532/// skipped (TTL guard, active tmux session guard, or nothing on disk to remove).
533/// Returns `Err` on unexpected failures (e.g. cannot resolve the repo root).
534pub(crate) async fn cleanup_task_worktree_with_opts(
535    task_id: &str,
536    repo: &str,
537    store: &Arc<TaskStore>,
538    opts: &JanitorOptions,
539) -> anyhow::Result<bool> {
540    let (worktree, branch, keep_remote_branch, merged) =
541        store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
542            .await
543            .map(|t| {
544                // Blocked tasks with a PR should keep their remote branch so the
545                // PR stays open for human review.
546                let keep = t.status == TaskStatus::Blocked && t.pr_number.is_some();
547                let merged = t.status == TaskStatus::Done;
548                (Some(t.worktree), Some(t.branch), keep, merged)
549            })
550            .unwrap_or((None, None, false, false));
551
552    let worktree_path = worktree.as_ref().map(std::path::PathBuf::from);
553
554    // Get worktrees base path
555    let worktrees_base = crate::home::worktrees_dir()
556        .unwrap_or_else(|_| std::path::PathBuf::from(".orch/worktrees"));
557
558    // Determine which directory to remove.
559    //
560    // Priority:
561    //   1. stored "worktree" path, if it exists on disk.
562    //   2. Construct from worktrees_base + project name + branch, if it exists.
563    let worktree_to_remove = if let Some(ref wt) = worktree_path {
564        if match tokio::fs::try_exists(wt).await {
565            Ok(true) => true,
566            Ok(false) => false,
567            Err(e) => {
568                tracing::warn!(task_id, worktree = %wt.display(), err = %e, "try_exists failed on stored worktree path — trying branch-based fallback");
569                false
570            }
571        } {
572            Some(wt.clone())
573        } else {
574            // The recorded worktree path doesn't exist — try reconstructing from branch.
575            if let Some(ref b) = branch {
576                let project = repo
577                    .rsplit('/')
578                    .next()
579                    .unwrap_or(repo)
580                    .trim_end_matches(".git");
581                let candidate = worktrees_base.join(project).join(b);
582                if match tokio::fs::try_exists(&candidate).await {
583                    Ok(true) => true,
584                    Ok(false) => false,
585                    Err(e) => {
586                        tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
587                        false
588                    }
589                } {
590                    Some(candidate)
591                } else {
592                    None
593                }
594            } else {
595                None
596            }
597        }
598    } else if let Some(ref b) = branch {
599        // No worktree path in store — try branch-based path.
600        let project = repo
601            .rsplit('/')
602            .next()
603            .unwrap_or(repo)
604            .trim_end_matches(".git");
605        let candidate = worktrees_base.join(project).join(b);
606        if match tokio::fs::try_exists(&candidate).await {
607            Ok(true) => true,
608            Ok(false) => false,
609            Err(e) => {
610                tracing::warn!(task_id, err = %e, "try_exists failed — assuming candidate does not exist");
611                false
612            }
613        } {
614            Some(candidate)
615        } else {
616            None
617        }
618    } else {
619        None
620    };
621
622    let mut did_clean = false;
623
624    // If there's nothing to remove (no worktree path and no branch),
625    // treat as a no-op and return Ok(false) without attempting to
626    // resolve the repo root. Resolving the repo root can fail for
627    // projects not registered in config and that should not make
628    // cleanup a hard error when there is nothing to do.
629    let branch_nonempty = branch.as_ref().filter(|b| !b.is_empty());
630    if worktree_to_remove.is_none() && branch_nonempty.is_none() {
631        // If the stored worktree path is non-empty but doesn't exist on disk,
632        // the worktree is already gone — mark it cleaned so we stop retrying.
633        if worktree_path
634            .as_ref()
635            .is_some_and(|p| !p.as_os_str().is_empty())
636        {
637            tracing::debug!(
638                task_id,
639                worktree = ?worktree_path,
640                "stored worktree path no longer exists on disk — marking cleaned"
641            );
642            if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
643                if let Err(e) = store.mark_cleaned(store_id).await {
644                    tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned in store — will retry");
645                }
646            }
647        }
648        return Ok(false);
649    }
650
651    if let Some(wt) = worktree_to_remove {
652        // TTL guard: skip if the worktree directory is too young.
653        if let Some(age_hours) = worktree_age_hours(&wt).await {
654            if age_hours < opts.ttl_hours {
655                tracing::debug!(
656                    task_id,
657                    worktree = %wt.display(),
658                    age_hours,
659                    ttl_hours = opts.ttl_hours,
660                    "worktree too young for cleanup, skipping"
661                );
662                return Ok(false);
663            }
664        }
665
666        // Tmux guard: skip if any active pane still has its cwd inside this worktree.
667        if is_worktree_in_active_session(&wt).await {
668            tracing::warn!(
669                task_id,
670                worktree = %wt.display(),
671                "worktree is referenced by an active tmux session — skipping cleanup"
672            );
673            return Ok(false);
674        }
675
676        // Validate git metadata before attempting any git operations.
677        // If the .git file points to a deleted gitdir the worktree is
678        // unrecoverable — force-remove the directory and skip git commands.
679        let gitdir_valid = crate::engine::runner::worktree::validate_worktree_gitdir(&wt).await;
680
681        if opts.dry_run {
682            tracing::info!(
683                task_id,
684                worktree = %wt.display(),
685                branch = ?branch,
686                gitdir_valid,
687                "[dry-run] would remove worktree and branch"
688            );
689        } else if !gitdir_valid {
690            tracing::warn!(
691                task_id,
692                worktree = %wt.display(),
693                "worktree has broken git metadata — force-removing directory without git operations"
694            );
695            match tokio::fs::remove_dir_all(&wt).await {
696                Ok(()) => {
697                    did_clean = true;
698                }
699                Err(e) => {
700                    tracing::warn!(
701                        task_id,
702                        worktree = %wt.display(),
703                        err = %e,
704                        "failed to force-remove broken worktree directory"
705                    );
706                }
707            }
708        } else {
709            tracing::info!(task_id, worktree = %wt.display(), "removing worktree");
710            // Derive repo root from the worktree itself (for cross-project support).
711            // If the worktree belongs to a different project than the current repo
712            // context (e.g., internal task with worktree in another project), we can
713            // still find the repo root by examining the worktree's .git file.
714            let repo_root = match resolve_repo_root_from_worktree(&wt).await {
715                Ok(root) => root,
716                Err(_) => resolve_repo_root(repo).await?,
717            };
718            if merged {
719                crate::engine::runner::cow::warm_from_merged(
720                    std::path::Path::new(&repo_root),
721                    &crate::engine::runner::worktree::project_name(std::path::Path::new(
722                        &repo_root,
723                    )),
724                    &wt,
725                )
726                .await;
727            }
728            let removed = remove_worktree_and_branch(
729                task_id,
730                &wt,
731                branch.as_deref(),
732                std::path::Path::new(&repo_root),
733                keep_remote_branch,
734            )
735            .await;
736            if removed {
737                did_clean = true;
738            } else {
739                tracing::warn!(
740                    task_id,
741                    worktree = %wt.display(),
742                    "worktree directory still exists after removal attempt — not marking as cleaned"
743                );
744            }
745        }
746    } else if let Some(ref br) = branch {
747        // Worktree directory is already gone, but the branch may still exist
748        // on the remote. Delete it to avoid orphaned branches.
749        if !opts.dry_run && !keep_remote_branch {
750            tracing::debug!(task_id, branch = %br, "no worktree on disk, cleaning up branch only");
751            // Resolve repo root lazily — branch cleanup needs it.
752            let repo_root = resolve_repo_root(repo).await?;
753            delete_branches(task_id, br, std::path::Path::new(&repo_root)).await;
754            did_clean = true;
755        }
756    }
757
758    if did_clean {
759        // Mark as cleaned in store so we don't retry next tick.
760        // If resolve_task_id returns None the task is not (or no longer) in
761        // the store; that is fine — we already did the on-disk work.
762        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
763            if let Err(e) = store.mark_cleaned(store_id).await {
764                tracing::warn!(task_id, err = %e, "failed to mark worktree cleaned — will retry");
765                // Don't log activity; retry next tick will do the actual cleanup
766                return Ok(did_clean);
767            }
768        }
769        store_log_activity(
770            &Some(Arc::clone(store)),
771            repo,
772            task_id,
773            "branch_delete",
774            None,
775            None,
776            None,
777            None,
778            Some(&serde_json::json!({
779                "worktree": worktree_path.map(|p| p.display().to_string()),
780                "branch": branch,
781                "keep_remote_branch": keep_remote_branch,
782            })),
783        )
784        .await;
785    }
786
787    Ok(did_clean)
788}
789
790/// Remove a git worktree directory and its local + remote branches.
791/// When `keep_remote_branch` is true, only the worktree and local branch are
792/// removed — the remote branch stays so that any linked PR remains open
793/// (used for blocked tasks awaiting human review).
794/// Returns `true` if the worktree directory is gone after the call
795/// (either we removed it or it was already absent).
796pub(crate) async fn remove_worktree_and_branch(
797    task_id: &str,
798    wt: &std::path::Path,
799    branch: Option<&str>,
800    repo_root: &std::path::Path,
801    keep_remote_branch: bool,
802) -> bool {
803    let wt_str = wt.to_string_lossy().to_string();
804    let repo_root_str = repo_root.to_string_lossy();
805
806    // Remove worktree FIRST, then delete the branch.
807    // Git refuses to remove a worktree if its branch is already deleted.
808    let remove_result = Command::new("git")
809        .args([
810            "-C",
811            repo_root_str.as_ref(),
812            "worktree",
813            "remove",
814            &wt_str,
815            "--force",
816        ])
817        .output_with_context()
818        .await;
819
820    match remove_result {
821        Ok(output) if output.status.success() => {
822            tracing::info!(task_id, "worktree removed (git metadata)");
823            // `git worktree remove` only removes git metadata — the physical directory
824            // with working files may remain. Clean it up with retry logic, since macOS
825            // sometimes needs a moment to release file handles from a recently-exited
826            // process. Retrying with a delay addresses "Directory not empty" errors.
827            const MAX_RETRIES: usize = 3;
828            const RETRY_DELAY_MS: u64 = 2000;
829            let mut removed = false;
830            for attempt in 0..MAX_RETRIES {
831                match tokio::fs::remove_dir_all(wt).await {
832                    Ok(()) => {
833                        removed = true;
834                        break;
835                    }
836                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
837                        // Directory was already removed by git or another cleanup pass.
838                        removed = true;
839                        break;
840                    }
841                    Err(e) if e.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
842                        if attempt < MAX_RETRIES - 1 {
843                            tracing::debug!(
844                                task_id,
845                                path = %wt.display(),
846                                attempt = attempt + 1,
847                                "worktree directory not empty — retrying after delay"
848                            );
849                            sleep(Duration::from_millis(RETRY_DELAY_MS)).await;
850                        } else {
851                            tracing::warn!(
852                                task_id,
853                                path = %wt.display(),
854                                err = %e,
855                                "failed to remove worktree directory after {} attempts (Directory not empty)",
856                                MAX_RETRIES
857                            );
858                        }
859                    }
860                    Err(e) => {
861                        tracing::warn!(task_id, path = %wt.display(), err = %e, "failed to remove worktree directory");
862                        break;
863                    }
864                }
865            }
866            if removed {
867                tracing::info!(task_id, path = %wt.display(), "worktree physical directory removed");
868            } else {
869                tracing::warn!(
870                    task_id,
871                    path = %wt.display(),
872                    "worktree directory still present after git worktree remove"
873                );
874            }
875        }
876        Ok(output) => {
877            let stderr = String::from_utf8_lossy(&output.stderr);
878            // "is not a working tree" means git's metadata is stale — the path
879            // exists on disk but is no longer registered as a worktree. Treat
880            // this as an already-gone worktree: physically remove the directory
881            // and prune stale git metadata. Downgrade to debug — this is an
882            // expected idempotency case, not a real error.
883            if stderr.contains("is not a working tree") {
884                tracing::debug!(
885                    task_id,
886                    path = %wt.display(),
887                    "path is not a registered worktree (stale metadata) — removing directory and pruning"
888                );
889                if let Err(e) = tokio::fs::remove_dir_all(wt).await {
890                    // Only warn if the directory actually still exists after removal attempt.
891                    if match tokio::fs::try_exists(wt).await {
892                        Ok(true) => true,
893                        Ok(false) => false,
894                        Err(e) => {
895                            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree does not exist");
896                            false
897                        }
898                    } {
899                        tracing::warn!(
900                            task_id,
901                            path = %wt.display(),
902                            err = %e,
903                            "failed to remove stale worktree directory"
904                        );
905                    }
906                } else {
907                    tracing::debug!(task_id, path = %wt.display(), "stale worktree directory removed");
908                }
909                // Prune stale worktree entries from git metadata so the warning
910                // stops recurring on future `git worktree list` / cleanup cycles.
911                if let Err(e) = Command::new("git")
912                    .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
913                    .output_with_context()
914                    .await
915                {
916                    tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
917                }
918            } else {
919                tracing::warn!(task_id, err = %stderr, "failed to remove worktree");
920            }
921        }
922        Err(e) => {
923            tracing::warn!(task_id, err = %e, "failed to remove worktree");
924        }
925    }
926
927    // Prune stale .git/worktrees/ entries left behind after removal.
928    if let Err(e) = Command::new("git")
929        .args(["-C", repo_root_str.as_ref(), "worktree", "prune"])
930        .output_with_context()
931        .await
932    {
933        tracing::warn!(task_id, err = %e, "git worktree prune failed — stale metadata may persist");
934    }
935
936    // Delete local and remote branch from the main repo root (worktree is already gone).
937    // When keep_remote_branch is set, only delete the local branch — the remote stays
938    // so any linked PR remains open for human review.
939    if let Some(br) = branch {
940        if keep_remote_branch {
941            // Only delete local branch
942            let branch_delete_result = Command::new("git")
943                .args(["-C", repo_root_str.as_ref(), "branch", "-D", br])
944                .output_with_context()
945                .await;
946            match branch_delete_result {
947                Ok(output) if output.status.success() => {
948                    tracing::info!(
949                        task_id,
950                        branch = br,
951                        "local branch deleted (keeping remote for open PR)"
952                    );
953                }
954                Ok(output) => {
955                    let stderr = String::from_utf8_lossy(&output.stderr);
956                    if !stderr.contains("not found") {
957                        tracing::debug!(task_id, branch = br, err = %stderr, "local branch delete failed (may already be gone)");
958                    }
959                }
960                Err(e) => {
961                    tracing::debug!(task_id, branch = br, err = %e, "local branch delete failed");
962                }
963            }
964        } else {
965            delete_branches(task_id, br, repo_root).await;
966        }
967    }
968
969    // Definitively check: is the worktree directory actually gone?
970    match tokio::fs::try_exists(wt).await {
971        Ok(false) => true, // confirmed gone
972        Ok(true) => false, // still there
973        Err(e) => {
974            tracing::warn!(task_id, err = %e, "try_exists failed — assuming worktree still present");
975            false // conservative: don't claim success
976        }
977    }
978}
979
980/// Delete local and remote branches for a task.
981pub(crate) async fn delete_branches(task_id: &str, br: &str, repo_root: &std::path::Path) {
982    let repo_root_str = repo_root.to_string_lossy();
983    let branch_delete_result = Command::new("git")
984        .args(["-C", repo_root_str.as_ref(), "branch", "-D", br])
985        .output_with_context()
986        .await;
987
988    match branch_delete_result {
989        Ok(output) if output.status.success() => {
990            tracing::info!(task_id, branch = %br, "local branch deleted");
991        }
992        Ok(output) => {
993            let stderr = String::from_utf8_lossy(&output.stderr);
994            tracing::debug!(
995                task_id,
996                err = %stderr,
997                "local branch delete skipped (may not exist)"
998            );
999        }
1000        Err(e) => {
1001            tracing::warn!(task_id, err = %e, "failed to delete local branch");
1002        }
1003    }
1004
1005    // Delete remote branch
1006    let auth_env = crate::engine::runner::git_ops::build_git_auth_env();
1007    let remote_delete = Command::new("git")
1008        .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1009        .args([
1010            "-C",
1011            repo_root_str.as_ref(),
1012            "-c",
1013            "lfs.locksverify=false",
1014            "push",
1015            "origin",
1016            "--delete",
1017            br,
1018        ])
1019        .output_with_context()
1020        .await;
1021
1022    match remote_delete {
1023        Ok(output) if output.status.success() => {
1024            tracing::info!(task_id, branch = %br, "remote branch deleted");
1025        }
1026        Ok(output) => {
1027            let stderr = String::from_utf8_lossy(&output.stderr);
1028            tracing::debug!(task_id, err = %stderr, "remote branch delete skipped");
1029        }
1030        Err(e) => {
1031            tracing::warn!(task_id, err = %e, "failed to delete remote branch");
1032        }
1033    }
1034}
1035
1036/// Returns `true` if any active tmux pane has its cwd inside `worktree`.
1037///
1038/// Uses `tmux list-panes -a -F '#{pane_current_path}'` to enumerate all
1039/// pane working directories across every session. If tmux is not running
1040/// (command fails), we conservatively return `false` so cleanup can proceed.
1041async fn is_worktree_in_active_session(worktree: &std::path::Path) -> bool {
1042    let output = Command::new("tmux")
1043        .args(["list-panes", "-a", "-F", "#{pane_current_path}"])
1044        .output()
1045        .await;
1046
1047    match output {
1048        Ok(o) if o.status.success() => {
1049            let worktree_str = worktree.to_string_lossy();
1050            String::from_utf8_lossy(&o.stdout).lines().any(|line| {
1051                let pane_path = line.trim();
1052                // Match exact path or any path that starts with worktree + separator.
1053                pane_path == worktree_str.as_ref()
1054                    || pane_path.starts_with(&format!("{}/", worktree_str))
1055            })
1056        }
1057        // tmux not running or no sessions — treat as safe to clean.
1058        _ => false,
1059    }
1060}
1061
1062/// Returns how many hours old the worktree directory is, based on its mtime.
1063///
1064/// Returns `None` if the directory does not exist or the mtime cannot be read.
1065async fn worktree_age_hours(worktree: &std::path::Path) -> Option<u64> {
1066    // Use tokio::fs to avoid blocking the async runtime when reading metadata.
1067    let metadata = tokio::fs::metadata(worktree).await.ok()?;
1068    let modified = metadata.modified().ok()?;
1069    let age = std::time::SystemTime::now().duration_since(modified).ok()?;
1070    Some(age.as_secs() / 3600)
1071}
1072
1073/// Resolve the repo root for an orphaned worktree and remove it.
1074///
1075/// Used by `orch service doctor --fix` and `orch service prune` to clean up worktrees that are
1076/// no longer owned by any task. Returns `true` if the worktree directory is gone
1077/// after the call.
1078pub(crate) async fn cleanup_orphaned_worktree(task_id: &str, wt: &std::path::Path) -> bool {
1079    let repo_root = match resolve_repo_root_for_orphaned_worktree(wt).await {
1080        Ok(root) => root,
1081        Err(e) => {
1082            tracing::warn!(
1083                worktree = %wt.display(),
1084                error = %e,
1085                "cannot resolve repo root for orphaned worktree"
1086            );
1087            return false;
1088        }
1089    };
1090    remove_worktree_and_branch(task_id, wt, None, std::path::Path::new(&repo_root), false).await
1091}
1092
1093/// Resolve the main git repository root for an orphaned worktree.
1094///
1095/// First tries to parse the `.git` file's `gitdir:` pointer (two levels up from
1096/// the pointer gives the bare repo root, which is the orch convention). Falls back
1097/// to looking up registered projects by directory name when the `.git` file is
1098/// absent or unparseable.
1099pub(crate) async fn resolve_repo_root_for_orphaned_worktree(
1100    wt: &std::path::Path,
1101) -> anyhow::Result<String> {
1102    let git_file = wt.join(".git");
1103    if tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1104        if let Ok(content) = tokio::fs::read_to_string(&git_file).await {
1105            for line in content.lines() {
1106                if let Some(gitdir) = line.strip_prefix("gitdir: ") {
1107                    // gitdir points to: <repo>/.git/worktrees/<name> (non-bare)
1108                    //                or <repo>.git/worktrees/<name>   (bare)
1109                    // Two levels up reaches the .git dir / bare repo root.
1110                    let gitdir_path = std::path::PathBuf::from(gitdir);
1111                    if let Some(repo_root) = gitdir_path.parent().and_then(|p| p.parent()) {
1112                        return Ok(repo_root.display().to_string());
1113                    }
1114                }
1115            }
1116        }
1117    }
1118
1119    // Fallback: look up registered projects by the project-name component of the
1120    // worktree path (format: ~/.orch/worktrees/<project>/<branch>).
1121    let worktrees_dir = crate::home::worktrees_dir()?;
1122    if let Ok(relative) = wt.strip_prefix(&worktrees_dir) {
1123        if let Some(project_name) = relative.components().next() {
1124            let project_name = project_name.as_os_str().to_string_lossy();
1125
1126            if let Ok(paths) = crate::config::get_project_paths() {
1127                for path_str in &paths {
1128                    let path = std::path::Path::new(path_str);
1129                    if let Some(name) = path.file_name() {
1130                        if name.to_string_lossy() == project_name {
1131                            return Ok(path_str.clone());
1132                        }
1133                    }
1134                }
1135            }
1136
1137            // Try bare clone at ~/.orch/projects/<owner>/<project>.git
1138            let parts: Vec<&str> = project_name.split('/').collect();
1139            let bare = if parts.len() == 2 {
1140                crate::home::projects_dir()
1141                    .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1142                    .unwrap_or_default()
1143            } else {
1144                return Err(anyhow::anyhow!(
1145                    "cannot determine owner for single-segment project name '{}' — \
1146                     bare repo location is ambiguous; register the project with 'owner/repo' format",
1147                    project_name
1148                ));
1149            };
1150            if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1151                return Ok(bare.display().to_string());
1152            }
1153        }
1154    }
1155
1156    anyhow::bail!(
1157        "cannot resolve repo root for orphaned worktree at {} — tried .git parsing and project lookup",
1158        wt.display()
1159    )
1160}
1161
1162/// Resolve the main git repository root from a worktree's .git file.
1163///
1164/// For cross-project worktree cleanup, we cannot rely on `resolve_repo_root` because
1165/// the worktree may belong to a different project than the current repo context.
1166/// Instead, we extract the repo root from the worktree's `.git` file which contains
1167/// a `gitdir:` pointer to the main repo's git directory.
1168pub async fn resolve_repo_root_from_worktree(wt: &std::path::Path) -> anyhow::Result<String> {
1169    // For worktrees, .git is a file (not a directory) containing gitdir path
1170    let git_file = wt.join(".git");
1171    if !tokio::fs::try_exists(&git_file).await.unwrap_or(false) {
1172        anyhow::bail!(".git file not found in worktree at {}", wt.display());
1173    }
1174
1175    let content = tokio::fs::read_to_string(&git_file)
1176        .await
1177        .map_err(|e| anyhow::anyhow!("failed to read .git file: {e}"))?;
1178
1179    // Parse gitdir line: "gitdir: /path/to/repo/.git/worktrees/<name>"
1180    for line in content.lines() {
1181        if let Some(gitdir) = line.strip_prefix("gitdir:") {
1182            let gitdir = gitdir.trim();
1183            // The main repo is the parent of the .git directory
1184            // gitdir points to: <repo>/.git/worktrees/<name>
1185            // We need to go up 3 levels: worktrees/<name> -> .git -> repo
1186            let gitdir_path = std::path::Path::new(gitdir);
1187            if let Some(repo_root) = gitdir_path
1188                .parent() // worktrees/<name>
1189                .and_then(|p| p.parent()) // .git
1190                .and_then(|p| p.parent())
1191            // repo root
1192            {
1193                return Ok(repo_root.display().to_string());
1194            }
1195        }
1196    }
1197
1198    anyhow::bail!(
1199        "cannot resolve repo root from worktree .git file at {}",
1200        wt.display()
1201    )
1202}
1203
1204/// Resolve the main git repository root path for a project.
1205///
1206/// Looks up the local project path from config, then verifies it's a git repo.
1207/// This avoids relying on cwd (which is undefined under launchd services).
1208pub(crate) async fn resolve_repo_root(repo: &str) -> anyhow::Result<String> {
1209    // Look up the local path from registered projects
1210    let paths = crate::config::get_project_paths().unwrap_or_default();
1211    for path_str in &paths {
1212        let path = std::path::Path::new(path_str);
1213        let orch_yml = path.join(".orch.yml");
1214        let legacy = path.join(".orchestrator.yml");
1215        let config_file = if tokio::fs::try_exists(&orch_yml).await.unwrap_or(false) {
1216            orch_yml
1217        } else if tokio::fs::try_exists(&legacy).await.unwrap_or(false) {
1218            legacy
1219        } else {
1220            continue;
1221        };
1222        if let Ok(content) = tokio::fs::read_to_string(&config_file).await {
1223            if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1224                if let Some(r) = doc
1225                    .get("gh")
1226                    .and_then(|gh| gh.get("repo"))
1227                    .and_then(|r| r.as_str())
1228                {
1229                    if r == repo {
1230                        return Ok(path_str.clone());
1231                    }
1232                }
1233            }
1234        }
1235    }
1236
1237    // Fallback: try bare clone in ~/.orch/projects/<owner>/<repo>.git
1238    let parts: Vec<&str> = repo.split('/').collect();
1239    let bare = if parts.len() == 2 {
1240        crate::home::projects_dir()
1241            .map(|d| d.join(parts[0]).join(format!("{}.git", parts[1])))
1242            .unwrap_or_default()
1243    } else {
1244        std::path::PathBuf::new()
1245    };
1246    if tokio::fs::try_exists(&bare).await.unwrap_or(false) {
1247        return Ok(bare.display().to_string());
1248    }
1249
1250    anyhow::bail!(
1251        "cannot find local path for project {repo} — \
1252         checked {} registered project(s) and bare clone at {}",
1253        paths.len(),
1254        bare.display()
1255    )
1256}
1257
1258/// Resolve the project directory for a given repo string.
1259///
1260/// Replicates the logic from `TaskRunner::resolve_project_dir()` without requiring
1261/// a Runner instance. Used by the orphan scanner to enumerate active worktrees.
1262fn resolve_project_dir_for_repo(repo: &str) -> anyhow::Result<std::path::PathBuf> {
1263    // Check PROJECT_DIR env var first
1264    if let Ok(dir) = std::env::var("PROJECT_DIR") {
1265        if !dir.is_empty() {
1266            return Ok(std::path::PathBuf::from(dir));
1267        }
1268    }
1269
1270    // Look up registered projects in config
1271    if let Ok(paths) = crate::config::get_project_paths() {
1272        for path_str in &paths {
1273            let path = std::path::Path::new(path_str);
1274            let orch_yml = path.join(".orch.yml");
1275            let legacy = path.join(".orchestrator.yml");
1276            let config_file = if std::path::Path::new(&orch_yml).exists() {
1277                orch_yml
1278            } else if std::path::Path::new(&legacy).exists() {
1279                legacy
1280            } else {
1281                continue;
1282            };
1283            if let Ok(content) = std::fs::read_to_string(&config_file) {
1284                if let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&content) {
1285                    if let Some(r) = doc
1286                        .get("gh")
1287                        .and_then(|gh| gh.get("repo"))
1288                        .and_then(|r| r.as_str())
1289                    {
1290                        if r == repo {
1291                            return Ok(path_str.clone().into());
1292                        }
1293                    }
1294                }
1295            }
1296        }
1297    }
1298
1299    anyhow::bail!("project dir not found for repo: {repo}")
1300}
1301
1302/// Check for merged PRs and update task status accordingly.
1303///
1304/// Queries status:in_review and status:needs_review tasks, checks if their PR
1305/// is merged, and updates status to done if merged.
1306pub(crate) async fn check_merged_prs(
1307    backend: &Arc<dyn ExternalBackend>,
1308    repo: &str,
1309    store: &Arc<TaskStore>,
1310    task_manager: &Arc<TaskManager>,
1311    in_review_tasks: &[ReviewTaskSnapshot],
1312    needs_review_tasks: &[ReviewTaskSnapshot],
1313) -> anyhow::Result<()> {
1314    let mut branch_map: std::collections::HashMap<String, String> =
1315        std::collections::HashMap::new();
1316    let all_review_tasks: Vec<&ReviewTaskSnapshot> = in_review_tasks
1317        .iter()
1318        .chain(needs_review_tasks.iter())
1319        .collect();
1320
1321    for task in &all_review_tasks {
1322        if !task.stored.branch.is_empty() {
1323            branch_map.insert(task.external.id.0.clone(), task.stored.branch.clone());
1324        }
1325    }
1326
1327    tracing::debug!(
1328        count = all_review_tasks.len(),
1329        "checking review tasks for merged PRs"
1330    );
1331
1332    // Collect (task_id, branch) pairs — skip tasks with no branch recorded.
1333    // Use the pre-built branch map; fall back to store lookup only for tasks
1334    // not in the map (e.g. internal tasks, backend fallback path).
1335    let mut task_branches: Vec<(String, String)> = Vec::new();
1336    for task in all_review_tasks {
1337        let task_id = &task.external.id.0;
1338        let branch = if let Some(b) = branch_map.get(task_id) {
1339            b.clone()
1340        } else {
1341            match store::opt_store_get_task(&Some(Arc::clone(store)), repo, task_id)
1342                .await
1343                .map(|t| t.branch)
1344            {
1345                Some(b) if !b.is_empty() => b,
1346                _ => {
1347                    tracing::debug!(task_id, "no branch info, skipping PR check");
1348                    continue;
1349                }
1350            }
1351        };
1352        task_branches.push((task_id.clone(), branch));
1353    }
1354
1355    if task_branches.is_empty() {
1356        return Ok(());
1357    }
1358
1359    // Single GraphQL call for all branches — N REST calls → 1 GraphQL call.
1360    let branches: Vec<String> = task_branches.iter().map(|(_, b)| b.clone()).collect();
1361    let merged_map = match backend.batch_is_pr_merged(&branches).await {
1362        Ok(map) => map,
1363        Err(e) => {
1364            tracing::warn!(
1365                err = %e,
1366                branch_count = branches.len(),
1367                "batch PR merge check failed, retrying affected branches individually"
1368            );
1369            fallback_is_pr_merged_by_branch(backend, &branches).await
1370        }
1371    };
1372
1373    for (task_id, branch) in task_branches {
1374        let is_merged = merged_map.get(&branch).copied().unwrap_or(false);
1375        if !is_merged {
1376            continue;
1377        }
1378
1379        tracing::info!(task_id, branch = %branch, "PR merged, marking task complete");
1380
1381        let id = ExternalId(task_id.clone());
1382        if let Err(e) = task_manager.update_task_status(&id, Status::Done).await {
1383            tracing::warn!(task_id, err = %e, "failed to update status to done");
1384            continue;
1385        }
1386
1387        let comment = format!(
1388            "PR merged, marking task complete{}",
1389            crate::engine::orch_footer()
1390        );
1391        if let Err(e) = backend.post_comment(&id, &comment).await {
1392            tracing::warn!(task_id, err = %e, "failed to post comment");
1393        }
1394    }
1395
1396    Ok(())
1397}
1398
1399async fn fallback_is_pr_merged_by_branch(
1400    backend: &Arc<dyn ExternalBackend>,
1401    branches: &[String],
1402) -> HashMap<String, bool> {
1403    let futs: Vec<_> = branches
1404        .iter()
1405        .map(|branch| {
1406            let backend = Arc::clone(backend);
1407            let branch = branch.clone();
1408            async move {
1409                match backend.is_pr_merged(&branch).await {
1410                    Ok(is_merged) => Some((branch, is_merged)),
1411                    Err(err) => {
1412                        tracing::warn!(
1413                            branch,
1414                            err = %err,
1415                            "fallback PR merge check failed for branch"
1416                        );
1417                        None
1418                    }
1419                }
1420            }
1421        })
1422        .collect();
1423
1424    futures::future::join_all(futs)
1425        .await
1426        .into_iter()
1427        .flatten()
1428        .collect()
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434    use crate::store::{NewTask, TaskStore};
1435    use async_trait::async_trait;
1436    use std::collections::HashMap;
1437    use std::sync::atomic::{AtomicUsize, Ordering};
1438    use std::sync::Mutex;
1439
1440    // worktree_age_hours
1441
1442    #[tokio::test]
1443    async fn worktree_age_hours_returns_none_for_missing_dir() {
1444        let tmp = tempfile::tempdir().unwrap();
1445        let missing = tmp.path().join("does-not-exist");
1446        assert!(worktree_age_hours(&missing).await.is_none());
1447    }
1448
1449    #[tokio::test]
1450    async fn worktree_age_hours_returns_some_for_existing_dir() {
1451        let tmp = tempfile::tempdir().unwrap();
1452        // A freshly created directory should have age ~0 hours.
1453        let age = worktree_age_hours(tmp.path()).await;
1454        assert!(age.is_some());
1455        assert_eq!(age.unwrap(), 0, "freshly created dir should have age 0h");
1456    }
1457
1458    // JanitorOptions::from_config defaults
1459
1460    #[test]
1461    fn janitor_options_default_values() {
1462        let opts = JanitorOptions::default();
1463        assert_eq!(opts.ttl_hours, 24);
1464        assert!(!opts.dry_run);
1465    }
1466
1467    // cleanup_task_worktree_with_opts TTL guard (skips if git missing)
1468
1469    /// Create a minimal git repo + worktree; return (tmp, worktree_dir).
1470    fn setup_test_repo() -> Option<(tempfile::TempDir, std::path::PathBuf)> {
1471        let tmp = tempfile::tempdir().ok()?;
1472
1473        // Create a real git repo (not bare) so we can add worktrees.
1474        let repo_dir = tmp.path().join("repo.git");
1475        std::fs::create_dir_all(&repo_dir).ok()?;
1476
1477        let init = std::process::Command::new("git")
1478            .args([
1479                "init",
1480                "--initial-branch=main",
1481                repo_dir
1482                    .to_str()
1483                    .expect("test repo path contains non-UTF-8 characters"),
1484            ])
1485            .output()
1486            .ok()?;
1487        if !init.status.success() {
1488            // Older git may not have --initial-branch; fall back.
1489            let _ = std::process::Command::new("git")
1490                .args([
1491                    "init",
1492                    repo_dir
1493                        .to_str()
1494                        .expect("test repo path contains non-UTF-8 characters"),
1495                ])
1496                .output()
1497                .ok()?;
1498        }
1499
1500        // Create an initial commit so the repo has a HEAD.
1501        let readme = repo_dir.join("README.md");
1502        std::fs::write(&readme, "# test").ok()?;
1503        std::process::Command::new("git")
1504            .args(["-C", repo_dir.to_str()?, "add", "."])
1505            .output()
1506            .ok()?;
1507        std::process::Command::new("git")
1508            .args([
1509                "-C",
1510                repo_dir.to_str()?,
1511                "-c",
1512                "user.email=test@test.com",
1513                "-c",
1514                "user.name=Test",
1515                "-c",
1516                "commit.gpgsign=false",
1517                "commit",
1518                "--allow-empty",
1519                "-m",
1520                "init",
1521            ])
1522            .output()
1523            .ok()?;
1524
1525        // Create a worktree branch.
1526        let wt_dir = tmp
1527            .path()
1528            .join("worktrees")
1529            .join("repo.git")
1530            .join("gh-issue-42-test");
1531        std::fs::create_dir_all(wt_dir.parent()?).ok()?;
1532        let wt_out = std::process::Command::new("git")
1533            .args([
1534                "-C",
1535                repo_dir.to_str()?,
1536                "worktree",
1537                "add",
1538                "-b",
1539                "gh-issue-42-test",
1540                wt_dir.to_str()?,
1541            ])
1542            .output()
1543            .ok()?;
1544
1545        if !wt_out.status.success() {
1546            return None;
1547        }
1548
1549        Some((tmp, wt_dir))
1550    }
1551
1552    #[tokio::test]
1553    async fn janitor_skips_young_worktree() {
1554        let Some((tmp, wt_dir)) = setup_test_repo() else {
1555            eprintln!("skipping test: git not available");
1556            return;
1557        };
1558
1559        // The worktree was just created → age is 0h < ttl_hours=24 → must be skipped.
1560        assert!(wt_dir.exists(), "worktree should exist before janitor runs");
1561
1562        // Run the TTL guard check directly (without a full engine).
1563        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1564        assert!(
1565            age < 24,
1566            "freshly created worktree should be younger than 24h (age={age}h)"
1567        );
1568
1569        // Explicitly verify: with ttl_hours=24 the worktree stays.
1570        let opts = JanitorOptions {
1571            ttl_hours: 24,
1572            dry_run: true,
1573        };
1574        // Age < ttl → should skip (worktree_age_hours < opts.ttl_hours).
1575        assert!(age < opts.ttl_hours, "janitor should skip young worktree");
1576
1577        drop(tmp); // cleanup tempdir
1578    }
1579
1580    #[tokio::test]
1581    async fn janitor_eligible_when_ttl_zero() {
1582        let Some((tmp, wt_dir)) = setup_test_repo() else {
1583            eprintln!("skipping test: git not available");
1584            return;
1585        };
1586
1587        // With ttl_hours=0 even a brand-new worktree is eligible.
1588        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1589        let opts = JanitorOptions {
1590            ttl_hours: 0,
1591            dry_run: true,
1592        };
1593        assert!(
1594            age >= opts.ttl_hours,
1595            "worktree should be eligible when ttl=0 (age={age}h)"
1596        );
1597
1598        drop(tmp);
1599    }
1600
1601    #[tokio::test]
1602    async fn dry_run_does_not_remove_worktree() {
1603        // Verify that with dry_run=true the worktree directory is NOT removed.
1604        let Some((tmp, wt_dir)) = setup_test_repo() else {
1605            eprintln!("skipping test: git not available");
1606            return;
1607        };
1608
1609        assert!(wt_dir.exists(), "worktree must exist before test");
1610
1611        // With dry_run=true and ttl_hours=0 the janitor would normally remove the
1612        // worktree, but dry_run prevents it.
1613        let opts = JanitorOptions {
1614            ttl_hours: 0,
1615            dry_run: true,
1616        };
1617
1618        // The janitor needs a repo_root to run git commands.  We point it at the
1619        // bare repo we created inside tmp (even though it's not registered in config).
1620        // Because resolve_repo_root will fail for our synthetic repo slug, we test the
1621        // inner helpers directly here.
1622        let age = worktree_age_hours(&wt_dir).await.unwrap_or(u64::MAX);
1623        let in_use = false; // no tmux running in tests
1624        let skip = age < opts.ttl_hours || in_use;
1625
1626        if !skip && opts.dry_run {
1627            // dry-run: log only, don't touch filesystem.
1628            tracing::debug!("[dry-run] would remove {}", wt_dir.display());
1629        } else if !skip {
1630            // This branch would do actual removal — but we're in dry_run mode above.
1631        }
1632
1633        // Worktree must still be there.
1634        assert!(
1635            wt_dir.exists(),
1636            "dry-run must not remove the worktree directory"
1637        );
1638
1639        drop(tmp);
1640    }
1641
1642    #[test]
1643    fn fallback_branch_path_construction() {
1644        // Verify the new fallback logic: when worktree path is absent but
1645        // branch is known, we should construct the path correctly.
1646        let tmp = tempfile::tempdir().unwrap();
1647        let wt_dir = tmp.path().join("myrepo").join("gh-issue-7-fix");
1648        std::fs::create_dir_all(&wt_dir).unwrap();
1649
1650        // Simulate: worktree=None, branch=Some("gh-issue-7-fix"), repo="owner/myrepo"
1651        let branch = "gh-issue-7-fix";
1652        let repo = "owner/myrepo";
1653        let worktrees_base = tmp.path();
1654
1655        let project = repo
1656            .rsplit('/')
1657            .next()
1658            .unwrap_or(repo)
1659            .trim_end_matches(".git");
1660        let candidate = worktrees_base.join(project).join(branch);
1661
1662        assert_eq!(candidate, wt_dir);
1663        assert!(
1664            candidate.exists(),
1665            "fallback path should point to existing dir"
1666        );
1667    }
1668
1669    /// A task with no worktree and no branch should return Ok(false) —
1670    /// nothing was cleaned, so git pull should NOT be triggered.
1671    #[tokio::test]
1672    async fn cleanup_returns_false_when_nothing_to_clean() {
1673        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1674
1675        // Create a done task with no worktree or branch fields set
1676        let id = store
1677            .create(&NewTask {
1678                external_id: Some("999".to_string()),
1679                repo: "owner/repo".to_string(),
1680                origin: "github".to_string(),
1681                title: "Already cleaned".to_string(),
1682                ..Default::default()
1683            })
1684            .await
1685            .unwrap();
1686        store
1687            .update_status(id, crate::store::TaskStatus::Done)
1688            .await
1689            .unwrap();
1690
1691        let opts = JanitorOptions {
1692            ttl_hours: 0,
1693            dry_run: false,
1694        };
1695
1696        let result = cleanup_task_worktree_with_opts("999", "owner/repo", &store, &opts).await;
1697        // resolve_repo_root will fail for synthetic repo — that's OK, the point is
1698        // that when nothing needs cleaning the function should not report true.
1699        // In production this means no git pull is triggered.
1700        match result {
1701            Ok(cleaned) => assert!(
1702                !cleaned,
1703                "cleanup should return false when there is no worktree or branch to clean"
1704            ),
1705            Err(_) => {
1706                // resolve_repo_root fails for test repo — acceptable.
1707                // The bug was that even with no worktree/branch, Ok(true) was returned.
1708            }
1709        }
1710    }
1711
1712    /// When the stored worktree path no longer exists on disk and there is no
1713    /// branch recorded, cleanup must mark the task as cleaned so subsequent
1714    /// runs do not keep retrying (idempotency regression test for #1021).
1715    #[tokio::test]
1716    async fn cleanup_marks_cleaned_when_stored_path_is_gone() {
1717        let tmp = tempfile::tempdir().unwrap();
1718        // A path that does NOT exist on disk.
1719        let stale_wt = tmp.path().join("stale-worktree");
1720        assert!(!stale_wt.exists(), "stale path must not exist");
1721
1722        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1723
1724        let id = store
1725            .create(&NewTask {
1726                external_id: Some("1005".to_string()),
1727                repo: "owner/repo".to_string(),
1728                origin: "github".to_string(),
1729                title: "Stale worktree task".to_string(),
1730                ..Default::default()
1731            })
1732            .await
1733            .unwrap();
1734        store
1735            .update_status(id, crate::store::TaskStatus::Done)
1736            .await
1737            .unwrap();
1738        // Set the stale worktree path on the task.
1739        store
1740            .set_fields(
1741                id,
1742                &[("worktree", stale_wt.to_string_lossy().as_ref().into())],
1743            )
1744            .await
1745            .unwrap();
1746
1747        // Verify the task starts out NOT cleaned.
1748        let before = store.get(id).await.unwrap();
1749        assert!(!before.worktree_cleaned, "task should not be cleaned yet");
1750        assert!(
1751            !before.worktree.is_empty(),
1752            "task must have a worktree path set"
1753        );
1754
1755        let opts = JanitorOptions {
1756            ttl_hours: 0,
1757            dry_run: false,
1758        };
1759
1760        let result = cleanup_task_worktree_with_opts("1005", "owner/repo", &store, &opts).await;
1761
1762        // The function should succeed (Ok) — stale path is not an error.
1763        // It returns Ok(false) because there was nothing to actively remove.
1764        match result {
1765            Ok(_) => {}
1766            Err(e) => panic!("cleanup should not error for stale path: {e}"),
1767        }
1768
1769        // The task must now be marked cleaned so the next cycle skips it.
1770        let after = store.get(id).await.unwrap();
1771        assert!(
1772            after.worktree_cleaned,
1773            "task must be marked cleaned when stored worktree path is already gone"
1774        );
1775    }
1776
1777    /// `remove_worktree_and_branch` returns true when the worktree directory
1778    /// is successfully removed (regression test for #1143).
1779    #[tokio::test]
1780    async fn remove_worktree_returns_true_on_success() {
1781        let Some((tmp, wt_dir)) = setup_test_repo() else {
1782            eprintln!("skipping test: git not available");
1783            return;
1784        };
1785
1786        let repo_dir = tmp.path().join("repo.git");
1787        assert!(wt_dir.exists(), "worktree must exist before removal");
1788
1789        let removed =
1790            remove_worktree_and_branch("42", &wt_dir, Some("gh-issue-42-test"), &repo_dir, false)
1791                .await;
1792
1793        assert!(
1794            removed,
1795            "remove_worktree_and_branch should return true when directory is gone"
1796        );
1797        assert!(!wt_dir.exists(), "worktree directory should be removed");
1798    }
1799
1800    /// Successful worktree cleanup must still delete the local branch.
1801    /// This guards against early-return regressions in `remove_worktree_and_branch`.
1802    #[tokio::test]
1803    async fn remove_worktree_success_still_deletes_local_branch() {
1804        let Some((tmp, wt_dir)) = setup_test_repo() else {
1805            eprintln!("skipping test: git not available");
1806            return;
1807        };
1808
1809        let repo_dir = tmp.path().join("repo.git");
1810        let repo_dir_str = repo_dir
1811            .to_str()
1812            .expect("test repo path contains non-UTF-8 characters");
1813        let branch = "gh-issue-42-test";
1814
1815        let before = std::process::Command::new("git")
1816            .args([
1817                "-C",
1818                repo_dir_str,
1819                "show-ref",
1820                "--verify",
1821                "--quiet",
1822                &format!("refs/heads/{branch}"),
1823            ])
1824            .status()
1825            .unwrap();
1826        assert!(
1827            before.success(),
1828            "test setup must create local branch before cleanup"
1829        );
1830
1831        let removed =
1832            remove_worktree_and_branch("42", &wt_dir, Some(branch), &repo_dir, false).await;
1833        assert!(removed, "worktree should be removed");
1834
1835        let after = std::process::Command::new("git")
1836            .args([
1837                "-C",
1838                repo_dir_str,
1839                "show-ref",
1840                "--verify",
1841                "--quiet",
1842                &format!("refs/heads/{branch}"),
1843            ])
1844            .status()
1845            .unwrap();
1846        assert!(
1847            !after.success(),
1848            "local branch should be deleted after successful worktree cleanup"
1849        );
1850    }
1851
1852    /// `remove_worktree_and_branch` returns false when the worktree directory
1853    /// cannot be removed (regression test for #1143 — prevents orphaned worktrees
1854    /// from being permanently marked as cleaned).
1855    #[tokio::test]
1856    async fn remove_worktree_returns_false_when_dir_persists() {
1857        let tmp = tempfile::tempdir().unwrap();
1858        // Create a plain directory (not a real git worktree) — git will fail
1859        // to remove it, so the directory stays on disk.
1860        let fake_wt = tmp.path().join("not-a-worktree");
1861        std::fs::create_dir_all(&fake_wt).unwrap();
1862
1863        // repo_root doesn't matter much — it just needs to exist for the -C flag.
1864        // Using tmp itself; git commands will fail but that's the point.
1865        let removed = remove_worktree_and_branch("99", &fake_wt, None, tmp.path(), false).await;
1866
1867        // The directory still exists because git couldn't remove it.
1868        // Before the fix, this would still have been marked as cleaned.
1869        assert!(
1870            !removed,
1871            "remove_worktree_and_branch should return false when directory still exists"
1872        );
1873        assert!(fake_wt.exists(), "directory should still be on disk");
1874    }
1875
1876    struct MergeCheckMockBackend {
1877        batch_result: Mutex<anyhow::Result<HashMap<String, bool>>>,
1878        single_results: HashMap<String, anyhow::Result<bool>>,
1879        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
1880        single_checked_branches: Arc<Mutex<Vec<String>>>,
1881    }
1882
1883    impl MergeCheckMockBackend {
1884        fn with_batch_error(
1885            error: anyhow::Error,
1886            single_results: HashMap<String, anyhow::Result<bool>>,
1887        ) -> Self {
1888            Self {
1889                batch_result: Mutex::new(Err(error)),
1890                single_results,
1891                posted_comments: Arc::new(Mutex::new(Vec::new())),
1892                single_checked_branches: Arc::new(Mutex::new(Vec::new())),
1893            }
1894        }
1895    }
1896
1897    #[async_trait]
1898    impl ExternalBackend for MergeCheckMockBackend {
1899        fn name(&self) -> &str {
1900            "mock"
1901        }
1902
1903        async fn create_task(
1904            &self,
1905            _title: &str,
1906            _body: &str,
1907            _labels: &[String],
1908        ) -> anyhow::Result<ExternalId> {
1909            anyhow::bail!("not implemented")
1910        }
1911
1912        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
1913            anyhow::bail!("not implemented")
1914        }
1915
1916        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
1917            Ok(vec![])
1918        }
1919
1920        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
1921            Ok(vec![])
1922        }
1923
1924        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
1925            self.posted_comments
1926                .lock()
1927                .unwrap()
1928                .push((id.0.clone(), body.to_string()));
1929            Ok(())
1930        }
1931
1932        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
1933            Ok(())
1934        }
1935
1936        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
1937            Ok(())
1938        }
1939
1940        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
1941            Ok(vec![])
1942        }
1943
1944        async fn create_sub_task(
1945            &self,
1946            _parent: &ExternalId,
1947            _title: &str,
1948            _body: &str,
1949            _labels: &[String],
1950        ) -> anyhow::Result<ExternalId> {
1951            anyhow::bail!("not implemented")
1952        }
1953
1954        async fn health_check(&self) -> anyhow::Result<()> {
1955            Ok(())
1956        }
1957
1958        async fn is_pr_merged(&self, branch: &str) -> anyhow::Result<bool> {
1959            self.single_checked_branches
1960                .lock()
1961                .unwrap()
1962                .push(branch.to_string());
1963            match self.single_results.get(branch) {
1964                Some(Ok(is_merged)) => Ok(*is_merged),
1965                Some(Err(err)) => Err(anyhow::anyhow!(err.to_string())),
1966                None => Ok(false),
1967            }
1968        }
1969
1970        async fn batch_is_pr_merged(
1971            &self,
1972            _branches: &[String],
1973        ) -> anyhow::Result<HashMap<String, bool>> {
1974            match &*self.batch_result.lock().unwrap() {
1975                Ok(map) => Ok(map.clone()),
1976                Err(err) => Err(anyhow::anyhow!(err.to_string())),
1977            }
1978        }
1979    }
1980
1981    #[tokio::test]
1982    async fn check_merged_prs_falls_back_to_single_branch_checks_on_batch_error() {
1983        let single_results = HashMap::from([
1984            ("branch-1".to_string(), Ok(true)),
1985            ("branch-2".to_string(), Ok(false)),
1986        ]);
1987        let backend = Arc::new(MergeCheckMockBackend::with_batch_error(
1988            anyhow::anyhow!("missing /data/repository in GraphQL response: no error details"),
1989            single_results,
1990        ));
1991        let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
1992
1993        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1994        let task1 = store
1995            .create(&NewTask {
1996                external_id: Some("101".to_string()),
1997                repo: "owner/repo".to_string(),
1998                origin: "github".to_string(),
1999                title: "Task 101".to_string(),
2000                ..Default::default()
2001            })
2002            .await
2003            .unwrap();
2004        store
2005            .update_status(task1, crate::store::TaskStatus::NeedsReview)
2006            .await
2007            .unwrap();
2008        store
2009            .set_fields(task1, &[("branch", serde_json::json!("branch-1"))])
2010            .await
2011            .unwrap();
2012
2013        let task2 = store
2014            .create(&NewTask {
2015                external_id: Some("102".to_string()),
2016                repo: "owner/repo".to_string(),
2017                origin: "github".to_string(),
2018                title: "Task 102".to_string(),
2019                ..Default::default()
2020            })
2021            .await
2022            .unwrap();
2023        store
2024            .update_status(task2, crate::store::TaskStatus::InReview)
2025            .await
2026            .unwrap();
2027        store
2028            .set_fields(task2, &[("branch", serde_json::json!("branch-2"))])
2029            .await
2030            .unwrap();
2031
2032        let task_manager = Arc::new(TaskManager::with_store(
2033            backend_dyn.clone(),
2034            store.clone(),
2035            "owner/repo".to_string(),
2036        ));
2037
2038        let in_review_tasks = store
2039            .list_by_status("owner/repo", crate::store::TaskStatus::InReview)
2040            .await
2041            .unwrap()
2042            .into_iter()
2043            .map(|stored| ReviewTaskSnapshot {
2044                external: crate::engine::tasks::store_task_to_external(&stored),
2045                stored,
2046            })
2047            .collect::<Vec<_>>();
2048        let needs_review_tasks = store
2049            .list_by_status("owner/repo", crate::store::TaskStatus::NeedsReview)
2050            .await
2051            .unwrap()
2052            .into_iter()
2053            .map(|stored| ReviewTaskSnapshot {
2054                external: crate::engine::tasks::store_task_to_external(&stored),
2055                stored,
2056            })
2057            .collect::<Vec<_>>();
2058
2059        check_merged_prs(
2060            &backend_dyn,
2061            "owner/repo",
2062            &store,
2063            &task_manager,
2064            &in_review_tasks,
2065            &needs_review_tasks,
2066        )
2067        .await
2068        .unwrap();
2069
2070        let checked = backend.single_checked_branches.lock().unwrap().clone();
2071        assert_eq!(
2072            checked,
2073            vec!["branch-2".to_string(), "branch-1".to_string()]
2074        );
2075
2076        let updated_task1 = store.get(task1).await.unwrap();
2077        let updated_task2 = store.get(task2).await.unwrap();
2078        assert_eq!(updated_task1.status, crate::store::TaskStatus::Done);
2079        assert_eq!(updated_task2.status, crate::store::TaskStatus::InReview);
2080
2081        let comments = backend.posted_comments.lock().unwrap().clone();
2082        assert_eq!(comments.len(), 1);
2083        assert_eq!(comments[0].0, "101");
2084        assert!(comments[0].1.contains("PR merged, marking task complete"));
2085    }
2086
2087    struct ReconciliationMockBackend {
2088        reconciliation_calls: Arc<AtomicUsize>,
2089    }
2090
2091    #[async_trait]
2092    impl ExternalBackend for ReconciliationMockBackend {
2093        fn name(&self) -> &str {
2094            "mock"
2095        }
2096
2097        async fn create_task(
2098            &self,
2099            _title: &str,
2100            _body: &str,
2101            _labels: &[String],
2102        ) -> anyhow::Result<ExternalId> {
2103            anyhow::bail!("not implemented")
2104        }
2105
2106        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
2107            anyhow::bail!("not implemented")
2108        }
2109
2110        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2111            Ok(vec![])
2112        }
2113
2114        async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
2115            self.reconciliation_calls.fetch_add(1, Ordering::SeqCst);
2116            Ok(vec![])
2117        }
2118
2119        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2120            Ok(vec![])
2121        }
2122
2123        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2124            Ok(())
2125        }
2126
2127        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2128            Ok(())
2129        }
2130
2131        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2132            Ok(())
2133        }
2134
2135        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2136            Ok(vec![])
2137        }
2138
2139        async fn create_sub_task(
2140            &self,
2141            _parent: &ExternalId,
2142            _title: &str,
2143            _body: &str,
2144            _labels: &[String],
2145        ) -> anyhow::Result<ExternalId> {
2146            anyhow::bail!("not implemented")
2147        }
2148
2149        async fn health_check(&self) -> anyhow::Result<()> {
2150            Ok(())
2151        }
2152    }
2153
2154    #[tokio::test]
2155    async fn cleanup_reconciliation_is_rate_limited_per_repo() {
2156        let repo = "owner/repo-cleanup-rate-limit";
2157        RECONCILIATION_LAST_RUN.remove(repo);
2158
2159        let backend = Arc::new(ReconciliationMockBackend {
2160            reconciliation_calls: Arc::new(AtomicUsize::new(0)),
2161        });
2162        let backend_dyn: Arc<dyn ExternalBackend> = backend.clone();
2163        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2164        let task_manager = Arc::new(TaskManager::with_store(
2165            backend_dyn.clone(),
2166            store.clone(),
2167            repo.to_string(),
2168        ));
2169        let opts = JanitorOptions {
2170            ttl_hours: 0,
2171            dry_run: true,
2172        };
2173
2174        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2175            .await
2176            .unwrap();
2177        cleanup_done_worktrees_with_opts(&backend_dyn, repo, &task_manager, &store, &opts)
2178            .await
2179            .unwrap();
2180
2181        assert_eq!(
2182            backend.reconciliation_calls.load(Ordering::SeqCst),
2183            1,
2184            "cleanup should not re-list reconciliation candidates on every sync pass"
2185        );
2186
2187        RECONCILIATION_LAST_RUN.remove(repo);
2188    }
2189}