Skip to main content

orch/engine/
tick.rs

1//! Core tick loop phases.
2//!
3//! The engine ticks every ~10 seconds. Each tick runs six phases:
4//! 1. Poll tmux for finished sessions
5//! 2. Recover stuck in-progress tasks
6//! 3. Route and dispatch:
7//!    - 3a. Route new tasks to agents
8//!    - 3b. Dispatch routed tasks (spawn agents)
9//! 4. Unblock parents whose children are all done
10//! 5. Run cron job scheduler
11//!
12//! [`tick_all`] runs them for every project. Phases 1-2 and 3b-5 run for all projects at
13//! once; only routing is serial, because it holds the router write lock. Store-wide work
14//! (`tick_global_sweeps`, `reconcile_dependencies`) runs once per tick, not once per project.
15
16use super::{EngineConfig, ProjectEngine};
17use crate::backends::github::GitHubBackend;
18use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
19use crate::channels::capture::CaptureService;
20use crate::channels::transport::Transport;
21use crate::cmd_cache;
22use crate::config;
23use crate::engine;
24use crate::engine::cooldown::{
25    SILENCE_AGENT_COOLDOWN_SECS, SILENCE_EXTENDED_COOLDOWN_SECS, github_circuit_remaining_secs,
26    is_agent_in_cooldown, is_github_circuit_open, is_model_in_cooldown,
27    record_agent_failure_with_message, record_silence_detection, set_agent_cooldown,
28    set_model_cooldown,
29};
30use crate::engine::dependencies;
31use crate::engine::dispatch_guard::DispatchGuard;
32use crate::engine::jobs;
33use crate::engine::priority;
34use crate::engine::priority::Priority;
35use crate::engine::router::config as router_config;
36use crate::engine::router::{AllAgentsCooledError, RouteResultError, Router, get_route_result};
37use crate::engine::runner::response;
38use crate::engine::runner::{TaskRunner, WeightSignal};
39use crate::engine::sync;
40use crate::engine::tasks;
41use crate::engine::tasks::{TaskManager, is_internal_id};
42use crate::github::http;
43use crate::repo_context::REPO_CONTEXT;
44use crate::store;
45use crate::store::StoreRoute;
46use crate::store::Task;
47use crate::store::TaskStatus;
48use crate::store::TaskStatus as DbStatus;
49use crate::store::TaskStore;
50use crate::store::WaitingOnDependency;
51use crate::store::{store_set_by_id, store_touch_updated_at};
52use crate::tmux::TmuxManager;
53use dashmap::mapref::entry::Entry;
54use dashmap::{DashMap, DashSet};
55use futures::future;
56use std::collections::HashMap;
57use std::collections::HashSet;
58use std::path::Path;
59use std::sync::Arc;
60use std::time::Duration;
61use std::time::{Instant, SystemTime};
62use tokio::sync::RwLock;
63use tokio::sync::{Semaphore, mpsc};
64use tokio::time;
65
66/// Per outer engine-loop quota for expensive routing calls.
67///
68/// Routing runs once per active project. Without a quota shared by all of them,
69/// `router.max_tasks_per_tick = 1` still permits one slow LLM routing call per
70/// project, multiplying 45s router timeouts into multi-minute watchdog stalls.
71pub(crate) struct RoutingTickQuota {
72    remaining: usize,
73}
74
75impl RoutingTickQuota {
76    pub(crate) fn new(max: usize) -> Self {
77        Self { remaining: max }
78    }
79
80    fn is_exhausted(&self) -> bool {
81        self.remaining == 0
82    }
83
84    fn remaining(&self) -> usize {
85        self.remaining
86    }
87
88    fn consume_one(&mut self) -> bool {
89        if self.remaining == 0 {
90            return false;
91        }
92        self.remaining -= 1;
93        true
94    }
95}
96
97/// Cleanup stale tmux sessions from previous engine runs.
98/// Called once from `serve()` before the main loop, never from a tick
99pub(crate) async fn startup_cleanup(tmux: &TmuxManager) {
100    let start_time = SystemTime::now()
101        .duration_since(SystemTime::UNIX_EPOCH)
102        .unwrap_or_default()
103        .as_secs();
104    tracing::info!(
105        start_time,
106        "running startup cleanup for stale tmux sessions"
107    );
108
109    match tmux.kill_stale_sessions(start_time).await {
110        Ok(killed) => {
111            if killed > 0 {
112                tracing::info!(killed, "startup cleanup killed stale sessions");
113            } else {
114                tracing::debug!("no stale tmux sessions found on startup");
115            }
116        }
117        Err(e) => {
118            tracing::warn!(error = %e, "failed to run startup cleanup");
119        }
120    }
121}
122
123async fn recover_routed_blocked_dispatch(
124    backend: &Arc<dyn ExternalBackend>,
125    store: &Arc<TaskStore>,
126    task_manager: &Arc<TaskManager>,
127    repo: &str,
128    task: &ExternalTask,
129    timing: &StuckTaskTiming,
130) {
131    tracing::warn!(
132        task_id = task.id.0,
133        age_mins = timing.age.num_minutes(),
134        threshold_mins = timing.threshold / 60,
135        "recovering routed task: dispatch blocked by stale tmux session → new"
136    );
137
138    let resolved_store_id = match store.resolve_task_id(repo, &task.id.0).await {
139        Ok(Some(id)) => Some(id),
140        Ok(None) => {
141            tracing::warn!(
142                task_id = %task.id.0,
143                repo,
144                "resolve_task_id returned None during routed-task recovery"
145            );
146            None
147        }
148        Err(e) => {
149            tracing::error!(
150                task_id = %task.id.0,
151                repo,
152                error = %e,
153                "resolve_task_id failed during routed-task recovery"
154            );
155            None
156        }
157    };
158
159    if let Some(store_id) = resolved_store_id {
160        if let Err(e) = store_set_by_id(
161            Some(store),
162            store_id,
163            &[
164                ("agent", serde_json::Value::Null),
165                ("model", serde_json::Value::Null),
166                ("route_attempts", serde_json::json!(0)),
167            ],
168        )
169        .await
170        {
171            tracing::warn!(
172                task_id = task.id.0,
173                ?e,
174                "failed to clear routing fields for routed stuck task — will retry next tick"
175            );
176            return;
177        }
178        finalize_open_runs_for_recovery(
179            store,
180            store_id,
181            "stuck-task recovery: stale session blocking dispatch killed",
182        )
183        .await;
184    }
185
186    if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
187        tracing::warn!(
188            task_id = task.id.0,
189            ?e,
190            "failed to reset routed stuck task status"
191        );
192        return;
193    }
194
195    store::store_log_activity(
196        &Some(Arc::clone(store)),
197        repo,
198        &task.id.0,
199        "rerouted",
200        Some("routed"),
201        Some("new"),
202        None::<&str>,
203        None::<&str>,
204        Some(&serde_json::json!({
205            "failure_reason": "routed_blocked_by_existing_session",
206            "age_minutes": timing.age.num_minutes(),
207            "had_session": true,
208        })),
209    )
210    .await;
211
212    if !is_internal_id(&task.id.0) {
213        let backend_clone = Arc::clone(backend);
214        let task_id_clone = task.id.clone();
215        let body = format!(
216            "[{}] recovered: stuck routed task — existing tmux session blocked dispatch for {}m; cleared route for retry{}",
217            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
218            timing.age.num_minutes(),
219            engine::orch_footer()
220        );
221        tokio::spawn(async move {
222            if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
223                tracing::warn!(
224                    task_id = task_id_clone.0,
225                    ?e,
226                    "failed to post routed-task recovery comment"
227                );
228            }
229        });
230    }
231}
232
233/// Phase 1 of tick: poll tmux for finished sessions and clean them up.
234pub(crate) async fn tick_check_session_completions(
235    tmux: &Arc<TmuxManager>,
236    repo: &str,
237    capture: &Arc<CaptureService>,
238    store: &Arc<TaskStore>,
239) -> anyhow::Result<()> {
240    let _span = tracing::info_span!("engine.tick.phase1.sessions").entered();
241    let session_snapshot = tmux.snapshot().await;
242    // Derive the short project name from repo (owner/repo -> repo), matching
243    // TmuxManager::session_name's derivation exactly (including the ".git" strip)
244    // so a repo configured with a ".git" suffix doesn't silently fail to match
245    // its own live sessions here — leaving stuck-task recovery as the only
246    // (later, coarser-grained) safety net for this repo's session completions.
247    let repo_name = repo
248        .rsplit('/')
249        .next()
250        .unwrap_or(repo)
251        .trim_end_matches(".git");
252    for (session, active) in session_snapshot {
253        // Only handle sessions that belong to this repo/project.
254        if session.project != repo_name {
255            continue;
256        }
257
258        if !active {
259            tracing::info!(
260                session = %session.name,
261                task_id = %session.task_id,
262                "session completed, collecting results"
263            );
264            // Touch the store.updated_at immediately when we observe a completed
265            // session so the stuck-task recovery phase doesn't incorrectly
266            // reclaim this task while the runner is still finishing
267            // post-processing (race: session observed dead → tick reclaim).
268            // Best-effort: no-ops if the task isn't present in the store.
269            //
270            // session.task_id uses hyphens (e.g. "internal-63714") because the
271            // tmux session name sanitizes colons to hyphens. resolve_task_id only
272            // handles "internal:" prefix, so we must convert back before touching.
273            // Review sessions carry a "-review" suffix tracked under the main task.
274            let store_task_id = {
275                let base = session
276                    .task_id
277                    .strip_suffix("-review")
278                    .unwrap_or(&session.task_id);
279                if let Some(n) = base.strip_prefix("internal-") {
280                    format!("internal:{n}")
281                } else {
282                    base.to_string()
283                }
284            };
285            store_touch_updated_at(&Some(Arc::clone(store)), repo, &store_task_id).await;
286            // Unregister from capture service using the task id the capture service
287            // was registered under (task id without project prefix).
288            capture.unregister_session(repo, &session.task_id).await;
289            // Kill the actual tmux session name we discovered (do not reconstruct)
290            if let Err(e) = tmux.kill_session(&session.name).await {
291                tracing::debug!(
292                    session = %session.name,
293                    ?e,
294                    "kill_session failed (session may already be gone)"
295                );
296            }
297        }
298    }
299    Ok(())
300}
301
302#[derive(Debug)]
303struct StuckTaskTiming {
304    has_session: bool,
305    age: chrono::Duration,
306    threshold: u64,
307}
308
309fn stuck_task_timing_from_map(
310    tmux: &Arc<TmuxManager>,
311    repo: &str,
312    session_task_id: &str,
313    updated_at: &str,
314    config: &EngineConfig,
315    parse_error_message: &'static str,
316    session_map: &HashMap<String, bool>,
317) -> Option<StuckTaskTiming> {
318    let has_session = session_alive(tmux, repo, session_task_id, session_map);
319    let threshold = if has_session {
320        config.stuck_timeout
321    } else {
322        config.no_session_stuck_timeout
323    };
324
325    let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
326        Ok(dt) => dt.with_timezone(&chrono::Utc),
327        Err(e) => {
328            tracing::warn!(
329                task_id = session_task_id,
330                updated_at,
331                ?e,
332                error_message = parse_error_message,
333                "cannot parse updated_at"
334            );
335            return None;
336        }
337    };
338
339    let raw_age = chrono::Utc::now() - updated;
340    // Host suspend (laptop sleep) freezes the process just like it freezes the agent
341    // running inside it — that frozen time is not evidence of a hang. Discount any
342    // detected suspend/resume gaps that occurred since this task was last touched.
343    let suspended = super::suspend::suspended_duration_since(updated);
344    let age = (raw_age - suspended).max(chrono::Duration::zero());
345    if age.num_seconds() <= i64::try_from(threshold).unwrap_or(i64::MAX) {
346        return None;
347    }
348
349    Some(StuckTaskTiming {
350        has_session,
351        age,
352        threshold,
353    })
354}
355
356fn session_alive(
357    tmux: &TmuxManager,
358    repo: &str,
359    session_task_id: &str,
360    session_map: &HashMap<String, bool>,
361) -> bool {
362    session_map
363        .get(&tmux.session_name(repo, session_task_id))
364        .copied()
365        .unwrap_or(false)
366}
367
368/// Start time and config for the stuck check of an in_progress task. A live session ages from
369/// the status entry, since other writes refresh `updated_at` mid-run, and is not stuck before
370/// its own run timeout ends. A dead session keeps `updated_at`, which the runner touches when it
371/// sees the session end
372async fn in_progress_stuck_clock(
373    store: &TaskStore,
374    repo: &str,
375    task_id: &str,
376    updated_at: &str,
377    has_session: bool,
378    config: &EngineConfig,
379) -> (String, EngineConfig) {
380    if !has_session {
381        return (updated_at.to_string(), config.clone());
382    }
383
384    let task = match store.resolve_task_id(repo, task_id).await {
385        Ok(Some(store_id)) => store.get(store_id).await.ok(),
386        _ => None,
387    };
388    let Some(task) = task else {
389        return (updated_at.to_string(), config.clone());
390    };
391
392    let entered_at = match store
393        .status_entered_at(task.id, TaskStatus::InProgress)
394        .await
395    {
396        Ok(Some(ts)) => ts,
397        Ok(None) => updated_at.to_string(),
398        Err(e) => {
399            tracing::warn!(
400                task_id,
401                error = %e,
402                "failed to read in_progress entry, falling back to updated_at"
403            );
404            updated_at.to_string()
405        }
406    };
407    let task_config = EngineConfig {
408        stuck_timeout: config.live_session_stuck_limit(Some(&task.complexity)),
409        ..config.clone()
410    };
411    (entered_at, task_config)
412}
413
414/// When the task last entered in_review, and whether a review decision landed since. Age from the
415/// status entry: `review_open_prs` rewrites `updated_at` on every pass, so it never ages
416async fn in_review_stuck_entry(store: &TaskStore, store_id: i64, fallback: &str) -> (String, bool) {
417    match store.in_review_entry(store_id).await {
418        Ok(Some(entry)) => entry,
419        Ok(None) => (fallback.to_string(), false),
420        Err(e) => {
421            tracing::warn!(
422                store_id,
423                error = %e,
424                "failed to read in_review entry, falling back to updated_at"
425            );
426            (fallback.to_string(), false)
427        }
428    }
429}
430
431/// Finalize any open `task_runs` rows for a task during recovery paths.
432/// Best-effort: logs a warning on failure but does not block recovery.
433async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
434    if let Err(e) = store
435        .finalize_incomplete_runs(store_id, "aborted", reason)
436        .await
437    {
438        tracing::warn!(
439            store_id,
440            error = %e,
441            reason,
442            "failed to finalize incomplete runs during recovery"
443        );
444    }
445}
446
447/// Phase 1b of tick: detect agents that have produced no output since session start
448/// and have exceeded the silence grace period.
449///
450/// Only triggers on complete silence (agent never produced any output). Agents that
451/// produced output then went quiet (e.g. long tool calls) are NOT affected.
452///
453/// On silence: kills the tmux session, applies model-level cooldown, resets the task
454/// to `New` so the router picks a different agent/model.
455pub(crate) async fn tick_detect_silent_agents(
456    tmux: &Arc<TmuxManager>,
457    repo: &str,
458    capture: &Arc<CaptureService>,
459    backend: &Arc<dyn ExternalBackend>,
460    task_manager: &Arc<TaskManager>,
461    config: &EngineConfig,
462    store: &Arc<TaskStore>,
463) -> anyhow::Result<()> {
464    let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
465    let grace = Duration::from_secs(config.silence_grace_period);
466    let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
467
468    for (task_id, session_name, silence_age_secs) in silent_sessions {
469        let use_backend = should_use_backend(&task_id);
470        // Look up agent + model from the store so we can cooldown the right model.
471        let store_task = match store.resolve_task_id(repo, &task_id).await {
472            Ok(Some(store_id)) => match store.get(store_id).await {
473                Ok(task) => Some(task),
474                Err(e) => {
475                    tracing::warn!(
476                        task_id,
477                        repo,
478                        store_id,
479                        operation = "store.get",
480                        error = %e,
481                        "silence detection: db error fetching task — agent/model cooldown will be skipped"
482                    );
483                    None
484                }
485            },
486            Ok(None) => {
487                tracing::debug!(
488                    task_id,
489                    repo,
490                    operation = "resolve_task_id",
491                    "silence detection: task not found in store (may have been cleaned up)"
492                );
493                None
494            }
495            Err(e) => {
496                tracing::warn!(
497                    task_id,
498                    repo,
499                    operation = "resolve_task_id",
500                    error = %e,
501                    "silence detection: db error resolving task id — agent/model cooldown will be skipped"
502                );
503                None
504            }
505        };
506        let agent_name = store_task
507            .as_ref()
508            .and_then(|t| t.agent.clone())
509            .unwrap_or_default();
510        let model_name = store_task
511            .as_ref()
512            .and_then(|t| t.model.clone())
513            .unwrap_or_default();
514
515        tracing::warn!(
516            task_id,
517            agent = %agent_name,
518            model = %model_name,
519            silence_age_secs,
520            grace_secs = config.silence_grace_period,
521            cooldown_secs = config.silence_cooldown,
522            "agent silent since session start — killing session, cooling down model + agent, failing over"
523        );
524
525        // 1. Kill the tmux session
526        if let Err(e) = tmux.kill_session(&session_name).await {
527            tracing::debug!(
528                task_id,
529                ?e,
530                "kill_session failed for silent agent (may already be gone)"
531            );
532        }
533
534        // 2. Unregister from capture
535        capture.unregister_session(repo, &task_id).await;
536
537        // Finalize any open audit run so the task_runs row is not orphaned
538        if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
539            finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
540                .await;
541        }
542
543        let mut extended_note = String::new();
544
545        // 3. Cooldown the specific model (not the whole agent)
546        if !agent_name.is_empty() && !model_name.is_empty() {
547            set_model_cooldown(
548                &agent_name,
549                &model_name,
550                config.silence_cooldown,
551                "silence_detected",
552            )
553            .await;
554            if let Some(result) = record_silence_detection(&agent_name, &model_name).await
555                && result.extended_cooldown_applied
556            {
557                extended_note = format!(
558                    " ({} silences in 24h -> extended cooldown {}s)",
559                    result.count, SILENCE_EXTENDED_COOLDOWN_SECS
560                );
561            }
562        }
563
564        // 3b. Short agent-level cooldown to force router to pick a different agent.
565        // Without this, the router picks the same agent with a different model,
566        // looping through all models (~2 min each) before the long agent cooldown kicks in.
567        if !agent_name.is_empty() {
568            set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
569        }
570
571        // 4. Pick a fallback agent and set to Routed (not New) to preserve progress.
572        // Setting to New would clear routing state and trigger a full LLM re-routing
573        // cycle, losing intermediate context. Routed skips re-routing and re-dispatches
574        // directly with the chosen fallback agent.
575        let task_eid = ExternalId(task_id.clone());
576
577        // Build available agents list and reroute chain for failover
578        let available: Vec<String> = engine::configured_agents()
579            .into_iter()
580            .filter(|a| cmd_cache::command_exists(a))
581            .collect();
582        let chain = response::get_reroute_chain(&task_id, &Some(Arc::clone(store)), repo).await;
583        let chain = response::update_reroute_chain(
584            &task_id,
585            &agent_name,
586            &chain,
587            &Some(Arc::clone(store)),
588            repo,
589        )
590        .await;
591
592        let (next_status, next_agent) = if let Some(fallback) =
593            response::pick_fallback_agent(&agent_name, &chain, &available)
594        {
595            tracing::info!(
596                task_id,
597                from = %agent_name,
598                to = %fallback,
599                "silence detection: failover to different agent, setting routed"
600            );
601            (Status::Routed, Some(fallback))
602        } else {
603            tracing::warn!(
604                task_id,
605                "silence detection: no fallback agents available, resetting to new for re-routing"
606            );
607            (Status::New, None)
608        };
609
610        if use_backend && let Some(ref st) = store_task {
611            // Fire-and-forget: label removals are cosmetic — tick should not block on them.
612            let stale_labels: Vec<String> = st
613                .labels
614                .iter()
615                .filter(|l| {
616                    l.starts_with("agent:")
617                        || l.starts_with("complexity:")
618                        || l.starts_with("model:")
619                })
620                .cloned()
621                .collect();
622            if !stale_labels.is_empty() {
623                let backend_clone = Arc::clone(backend);
624                let task_eid_clone = task_eid.clone();
625                tokio::spawn(async move {
626                    for label in &stale_labels {
627                        if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await {
628                            tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
629                        }
630                    }
631                });
632            }
633        }
634
635        if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
636            if let Some(ref fallback) = next_agent {
637                // Write the fallback agent into the store so dispatch can proceed
638                // directly without an LLM re-routing cycle. Clearing model forces
639                // model_for_complexity to pick the best available model for the new agent.
640                let _ = store_set_by_id(
641                    Some(store),
642                    store_id,
643                    &[
644                        ("agent", serde_json::json!(fallback)),
645                        ("model", serde_json::json!("")),
646                        (
647                            "last_error",
648                            serde_json::json!(format!(
649                                "silence detected after {}s, failing over to {}",
650                                config.silence_grace_period, fallback
651                            )),
652                        ),
653                    ],
654                )
655                .await;
656            } else {
657                let _ = store_set_by_id(
658                    Some(store),
659                    store_id,
660                    &[
661                        ("agent", serde_json::Value::Null),
662                        ("model", serde_json::Value::Null),
663                        (
664                            "last_error",
665                            serde_json::json!(format!(
666                                "silence detected after {}s, no fallback agents available",
667                                config.silence_grace_period
668                            )),
669                        ),
670                    ],
671                )
672                .await;
673            }
674        }
675
676        if let Err(e) = task_manager
677            .update_task_status(&task_eid, next_status)
678            .await
679        {
680            tracing::warn!(task_id, ?e, "failed to update silent task status");
681            continue;
682        }
683
684        // Log reroute activity
685        let details = serde_json::json!({
686            "failure_reason": "agent_silence",
687            "silence_duration_secs": config.silence_grace_period,
688            "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
689            "fallback_available": next_agent.is_some(),
690        });
691        store::store_log_activity(
692            &Some(Arc::clone(store)),
693            repo,
694            &task_id,
695            "rerouted",
696            Some("in_progress"),
697            Some(next_status.as_str()),
698            next_agent.as_deref(),
699            None,
700            Some(&details),
701        )
702        .await;
703
704        // 5. Post a comment explaining what happened
705        let action = if let Some(ref fallback) = next_agent {
706            format!("failing over to {fallback}")
707        } else {
708            "marking needs_review (no fallback agents)".to_string()
709        };
710        let comment = format!(
711            "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
712            chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
713            config.silence_grace_period,
714            agent_name,
715            model_name,
716            config.silence_cooldown,
717            extended_note,
718            agent_name,
719            SILENCE_AGENT_COOLDOWN_SECS,
720            action,
721            engine::orch_footer(),
722        );
723        if use_backend {
724            // Fire-and-forget: comment is informational — tick should not block on it.
725            let backend_clone = Arc::clone(backend);
726            let task_eid_clone = task_eid.clone();
727            tokio::spawn(async move {
728                if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
729                    tracing::warn!(
730                        task_id = task_eid_clone.0,
731                        ?e,
732                        "failed to post silence detection comment"
733                    );
734                }
735            });
736        }
737    }
738
739    Ok(())
740}
741
742fn should_use_backend(task_id: &str) -> bool {
743    !is_internal_id(task_id)
744}
745
746/// Phase 2 of tick: recover tasks stranded by missing or stale tmux state.
747/// - `in_progress` tasks → reset to `New` when their session is missing or the run timed out
748/// - `routed` tasks      → reset to `New` when an old live session keeps blocking re-dispatch
749/// - `in_review` tasks   → reset to `NeedsReview` when the review session disappeared
750#[expect(
751    clippy::too_many_arguments,
752    reason = "each argument is engine state the tick passes down"
753)]
754pub(crate) async fn tick_recover_stuck_tasks(
755    backend: &Arc<dyn ExternalBackend>,
756    tmux: &Arc<TmuxManager>,
757    repo: &str,
758    task_manager: &Arc<TaskManager>,
759    config: &EngineConfig,
760    store: &Arc<TaskStore>,
761    session_map: &HashMap<String, bool>,
762    dispatching: &Arc<DashMap<String, String>>,
763) -> anyhow::Result<()> {
764    let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
765
766    let in_progress = match task_manager
767        .list_external_by_status(Status::InProgress)
768        .await
769    {
770        Ok(tasks) => tasks,
771        Err(e) => {
772            tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
773            vec![]
774        }
775    };
776    for task in &in_progress {
777        let (since, task_config) = in_progress_stuck_clock(
778            store,
779            repo,
780            &task.id.0,
781            &task.updated_at,
782            session_alive(tmux, repo, &task.id.0, session_map),
783            config,
784        )
785        .await;
786        let Some(timing) = stuck_task_timing_from_map(
787            tmux,
788            repo,
789            &task.id.0,
790            &since,
791            &task_config,
792            "cannot parse updated_at, skipping stuck-task check",
793            session_map,
794        ) else {
795            continue;
796        };
797
798        // A dead tmux session with no `updated_at` refresh in a long time normally means
799        // the runner vanished (crashed, orch restarted). But it also matches the tail end
800        // of a *successful* run: the session exits the instant the agent finishes, while
801        // the runner is still polling for that exit (up to its own poll interval) and then
802        // writing results. If the runner's dispatch guard for this task is still held, the
803        // runner is alive and actively processing — let it finish rather than racing it.
804        if !timing.has_session {
805            let dispatch_key = format!("{repo}/{}", task.id.0);
806            if dispatching.contains_key(&dispatch_key) {
807                // Promoted from debug! to info!: the default log filter is "orch=info"
808                // (see main.rs), so this line — the only direct evidence that the guard
809                // check actually found and honored a held dispatch guard — was silently
810                // dropped in production. Every prior occurrence of the reclaim race
811                // (#3518/#3519/#3523/#3525/#3526) had to be diagnosed from timestamp
812                // correlation alone because this and the "dispatch guard
813                // claimed"/"released" lines never appeared in orch.log.
814                tracing::info!(
815                    task_id = task.id.0,
816                    dispatch_key,
817                    age_mins = timing.age.num_minutes(),
818                    "skipping stuck-task reclaim: runner still actively processing this task"
819                );
820                continue;
821            }
822        }
823
824        if timing.has_session {
825            tracing::warn!(
826                task_id = task.id.0,
827                age_mins = timing.age.num_minutes(),
828                threshold_mins = timing.threshold / 60,
829                "recovering stuck task: timed out with active session → new"
830            );
831            // The agent would keep working under a task that is routed again, and its session
832            // name would block the next dispatch
833            let session_name = tmux.session_name(repo, &task.id.0);
834            if let Err(e) = tmux.kill_session(&session_name).await {
835                tracing::warn!(
836                    task_id = task.id.0,
837                    session = %session_name,
838                    error = %e,
839                    "failed to kill stuck tmux session, will retry next tick"
840                );
841                continue;
842            }
843        } else {
844            tracing::warn!(
845                task_id = task.id.0,
846                dispatch_key = format!("{repo}/{}", task.id.0),
847                age_mins = timing.age.num_minutes(),
848                threshold_mins = timing.threshold / 60,
849                "recovering stuck task: no session found — reclaiming early → new"
850            );
851        }
852        // For stuck tasks with an active session, record agent/model failure + cooldown so the
853        // router picks a different agent/model on the next attempt. Without this, the router
854        // sees the agent as healthy and selects it again, causing the hang to repeat.
855        // (Mirrors the pattern in tick_detect_silent_agents lines 219–284.)
856        let mut cached_store_id: Option<i64> = None;
857        if timing.has_session {
858            let store_task = match store.resolve_task_id(repo, &task.id.0).await {
859                Ok(Some(store_id)) => {
860                    cached_store_id = Some(store_id);
861                    match store.get(store_id).await {
862                        Ok(t) => Some(t),
863                        Err(e) => {
864                            tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
865                            None
866                        }
867                    }
868                }
869                Ok(None) => None,
870                Err(e) => {
871                    tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
872                    None
873                }
874            };
875            let agent_name = store_task
876                .as_ref()
877                .and_then(|t| t.agent.clone())
878                .unwrap_or_default();
879            let model_name = store_task
880                .as_ref()
881                .and_then(|t| t.model.clone())
882                .unwrap_or_default();
883
884            if !agent_name.is_empty() && !model_name.is_empty() {
885                set_model_cooldown(
886                    &agent_name,
887                    &model_name,
888                    config.silence_cooldown,
889                    "silence_detected",
890                )
891                .await;
892                record_agent_failure_with_message(
893                    &agent_name,
894                    &format!(
895                        "stuck with active session after {}m",
896                        timing.age.num_minutes()
897                    ),
898                )
899                .await;
900            }
901            if !agent_name.is_empty() {
902                set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
903            }
904            tracing::warn!(
905                task_id = task.id.0,
906                agent = %agent_name,
907                model = %model_name,
908                cooldown_secs = config.silence_cooldown,
909                "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
910            );
911        }
912
913        // Remove stale agent/model labels so the LLM router re-routes properly.
914        // Fire-and-forget: cosmetic label operation, tick should not block on it.
915        {
916            let stale_labels: Vec<String> = task
917                .labels
918                .iter()
919                .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
920                .cloned()
921                .collect();
922            if !stale_labels.is_empty() {
923                let backend_clone = Arc::clone(backend);
924                let task_id_clone = task.id.clone();
925                tokio::spawn(async move {
926                    for label in &stale_labels {
927                        if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
928                            tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
929                        }
930                    }
931                });
932            }
933        }
934        let resolved_store_id = match cached_store_id {
935            Some(id) => Some(id),
936            None => match store.resolve_task_id(repo, &task.id.0).await {
937                Ok(Some(id)) => Some(id),
938                Ok(None) => {
939                    tracing::warn!(
940                        task_id = %task.id.0,
941                        repo,
942                        "resolve_task_id returned None during stuck-task recovery"
943                    );
944                    None
945                }
946                Err(e) => {
947                    tracing::error!(
948                        task_id = %task.id.0,
949                        repo,
950                        error = %e,
951                        "resolve_task_id failed during stuck-task recovery"
952                    );
953                    None
954                }
955            },
956        };
957        if let Some(store_id) = resolved_store_id {
958            if let Err(e) = store_set_by_id(
959                Some(store),
960                store_id,
961                &[
962                    ("agent", serde_json::Value::Null),
963                    ("model", serde_json::Value::Null),
964                    ("route_attempts", serde_json::json!(0)),
965                ],
966            )
967            .await
968            {
969                tracing::warn!(
970                    task_id = task.id.0,
971                    ?e,
972                    "failed to clear routing fields for stuck task — will retry next tick"
973                );
974                continue;
975            }
976            finalize_open_runs_for_recovery(
977                store,
978                store_id,
979                if timing.has_session {
980                    "stuck-task recovery: active session killed"
981                } else {
982                    "stuck-task recovery: no session found"
983                },
984            )
985            .await;
986        }
987        if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
988            tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
989            continue;
990        }
991
992        // Log reroute activity for stuck task recovery
993        let details = serde_json::json!({
994            "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
995            "age_minutes": timing.age.num_minutes(),
996            "had_session": timing.has_session,
997        });
998        store::store_log_activity(
999            &Some(Arc::clone(store)),
1000            repo,
1001            &task.id.0,
1002            "rerouted",
1003            Some("in_progress"),
1004            Some("new"),
1005            None::<&str>,
1006            None::<&str>,
1007            Some(&details),
1008        )
1009        .await;
1010
1011        let reason = if timing.has_session {
1012            format!(
1013                "timed out after {}m with active session (cleared agent for re-routing)",
1014                timing.age.num_minutes()
1015            )
1016        } else {
1017            format!(
1018                "no session found — reclaiming early after {}m (cleared agent for re-routing)",
1019                timing.age.num_minutes()
1020            )
1021        };
1022        // Fire-and-forget: comment is informational — tick should not block on it.
1023        {
1024            let backend_clone = Arc::clone(backend);
1025            let task_id_clone = task.id.clone();
1026            let body = format!(
1027                "[{}] recovered: stuck in_progress — {}{}",
1028                chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1029                reason,
1030                engine::orch_footer()
1031            );
1032            tokio::spawn(async move {
1033                if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
1034                    tracing::warn!(
1035                        task_id = task_id_clone.0,
1036                        ?e,
1037                        "failed to post stuck-task recovery comment"
1038                    );
1039                }
1040            });
1041        }
1042    }
1043
1044    let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
1045        Ok(tasks) => tasks,
1046        Err(e) => {
1047            tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
1048            vec![]
1049        }
1050    };
1051    match task_manager
1052        .list_internal_by_status(TaskStatus::Routed)
1053        .await
1054    {
1055        Ok(tasks) => routed.extend(tasks),
1056        Err(e) => tracing::warn!(
1057            ?e,
1058            "failed to list internal routed tasks for stuck recovery"
1059        ),
1060    }
1061    for task in &routed {
1062        let Some(timing) = stuck_task_timing_from_map(
1063            tmux,
1064            repo,
1065            &task.id.0,
1066            &task.updated_at,
1067            config,
1068            "cannot parse updated_at, skipping routed stuck-task check",
1069            session_map,
1070        ) else {
1071            continue;
1072        };
1073
1074        if !timing.has_session {
1075            continue;
1076        }
1077
1078        let session_name = tmux.session_name(repo, &task.id.0);
1079        if let Err(e) = tmux.kill_session(&session_name).await {
1080            tracing::warn!(
1081                task_id = task.id.0,
1082                session = %session_name,
1083                error = %e,
1084                "failed to kill stale tmux session blocking routed task"
1085            );
1086            continue;
1087        }
1088
1089        recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
1090    }
1091
1092    // Recover internal (SQLite) tasks stuck in in_progress.
1093    // These have no GitHub labels or comments — just reset the DB status to New.
1094    let internal_in_progress = match task_manager
1095        .list_internal_by_status(DbStatus::InProgress)
1096        .await
1097    {
1098        Ok(tasks) => tasks,
1099        Err(e) => {
1100            tracing::warn!(
1101                ?e,
1102                "failed to list internal in_progress tasks for stuck recovery"
1103            );
1104            vec![]
1105        }
1106    };
1107    for task in &internal_in_progress {
1108        let task_id = task.id.0.clone();
1109        let (since, task_config) = in_progress_stuck_clock(
1110            store,
1111            repo,
1112            &task_id,
1113            &task.updated_at,
1114            session_alive(tmux, repo, &task_id, session_map),
1115            config,
1116        )
1117        .await;
1118        let Some(timing) = stuck_task_timing_from_map(
1119            tmux,
1120            repo,
1121            &task_id,
1122            &since,
1123            &task_config,
1124            "cannot parse updated_at, skipping stuck internal-task check",
1125            session_map,
1126        ) else {
1127            continue;
1128        };
1129
1130        // See the comment on the external in-progress loop above: a dead session with a
1131        // stale `updated_at` also matches the tail end of a run that just finished
1132        // successfully. Skip the reclaim while the runner's dispatch guard for this task
1133        // is still held — this is the exact race that discards completed daily/scheduled
1134        // internal tasks whose total runtime exceeds `no_session_stuck_timeout`.
1135        if !timing.has_session {
1136            let dispatch_key = format!("{repo}/{task_id}");
1137            if dispatching.contains_key(&dispatch_key) {
1138                // Promoted from debug! to info! (issue #3526): the default log filter is
1139                // "orch=info" (main.rs), so this line — along with the DispatchGuard
1140                // claim/release lines in dispatch_guard.rs and dispatch_tasks_for_repo —
1141                // never reached orch.log in production. Every occurrence of the reclaim
1142                // race (#3518, #3519, #3523, #3525, #3526) had to be reasoned about from
1143                // timestamp correlation across unrelated log lines instead of directly
1144                // observing whether the guard was held, because the one log line that
1145                // would prove it was silently filtered out. Promoting these closes that
1146                // observability gap so the next occurrence carries conclusive evidence.
1147                tracing::info!(
1148                    task_id,
1149                    dispatch_key,
1150                    age_mins = timing.age.num_minutes(),
1151                    "skipping stuck-task reclaim: runner still actively processing this task"
1152                );
1153                continue;
1154            }
1155        }
1156
1157        if timing.has_session {
1158            tracing::warn!(
1159                task_id,
1160                age_mins = timing.age.num_minutes(),
1161                threshold_mins = timing.threshold / 60,
1162                "recovering stuck task: timed out with active session → new"
1163            );
1164            let session_name = tmux.session_name(repo, &task_id);
1165            if let Err(e) = tmux.kill_session(&session_name).await {
1166                tracing::warn!(
1167                    task_id,
1168                    session = %session_name,
1169                    error = %e,
1170                    "failed to kill stuck tmux session, will retry next tick"
1171                );
1172                continue;
1173            }
1174        } else {
1175            tracing::warn!(
1176                task_id,
1177                dispatch_key = format!("{repo}/{task_id}"),
1178                age_mins = timing.age.num_minutes(),
1179                threshold_mins = timing.threshold / 60,
1180                "recovering stuck task: no session found — reclaiming early → new"
1181            );
1182        }
1183        // Apply agent/model cooldown for internal stuck tasks with active sessions,
1184        // mirroring the external path (lines 501-546). Without this, the router picks
1185        // the same agent/model that caused the hang, creating an infinite loop.
1186        let mut cached_store_id: Option<i64> = None;
1187        if timing.has_session {
1188            let store_task = match store.resolve_task_id(repo, &task_id).await {
1189                Ok(Some(store_id)) => {
1190                    cached_store_id = Some(store_id);
1191                    match store.get(store_id).await {
1192                        Ok(t) => Some(t),
1193                        Err(e) => {
1194                            tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
1195                            None
1196                        }
1197                    }
1198                }
1199                Ok(None) => None,
1200                Err(e) => {
1201                    tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
1202                    None
1203                }
1204            };
1205            let agent_name = store_task
1206                .as_ref()
1207                .and_then(|t| t.agent.clone())
1208                .unwrap_or_default();
1209            let model_name = store_task
1210                .as_ref()
1211                .and_then(|t| t.model.clone())
1212                .unwrap_or_default();
1213
1214            if !agent_name.is_empty() && !model_name.is_empty() {
1215                set_model_cooldown(
1216                    &agent_name,
1217                    &model_name,
1218                    config.silence_cooldown,
1219                    "silence_detected",
1220                )
1221                .await;
1222                record_agent_failure_with_message(
1223                    &agent_name,
1224                    &format!(
1225                        "internal task stuck with active session after {}m",
1226                        timing.age.num_minutes()
1227                    ),
1228                )
1229                .await;
1230            }
1231            if !agent_name.is_empty() {
1232                set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
1233            }
1234            tracing::warn!(
1235                task_id,
1236                agent = %agent_name,
1237                model = %model_name,
1238                cooldown_secs = config.silence_cooldown,
1239                "internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
1240            );
1241        }
1242        // Reset routing state so the LLM router is used on the next attempt
1243        // (same reset that external tasks perform).
1244        let resolved_store_id = match cached_store_id {
1245            Some(id) => Some(id),
1246            None => match store.resolve_task_id(repo, &task_id).await {
1247                Ok(Some(id)) => Some(id),
1248                Ok(None) => {
1249                    tracing::warn!(
1250                        task_id = %task_id,
1251                        repo,
1252                        "resolve_task_id returned None during stuck internal-task recovery"
1253                    );
1254                    None
1255                }
1256                Err(e) => {
1257                    tracing::error!(
1258                        task_id = %task_id,
1259                        repo,
1260                        error = %e,
1261                        "resolve_task_id failed during stuck internal-task recovery"
1262                    );
1263                    None
1264                }
1265            },
1266        };
1267        if let Some(store_id) = resolved_store_id {
1268            if let Err(e) = store_set_by_id(
1269                Some(store),
1270                store_id,
1271                &[
1272                    ("agent", serde_json::Value::Null),
1273                    ("model", serde_json::Value::Null),
1274                    ("route_attempts", serde_json::json!(0)),
1275                ],
1276            )
1277            .await
1278            {
1279                tracing::warn!(
1280                    task_id,
1281                    ?e,
1282                    "failed to clear routing fields for stuck internal task — will retry next tick"
1283                );
1284                continue;
1285            }
1286            finalize_open_runs_for_recovery(
1287                store,
1288                store_id,
1289                if timing.has_session {
1290                    "stuck-task recovery: internal active session killed"
1291                } else {
1292                    "stuck-task recovery: internal no session found"
1293                },
1294            )
1295            .await;
1296        }
1297        if let Err(e) = task_manager
1298            .update_task_status(&ExternalId(task_id.clone()), Status::New)
1299            .await
1300        {
1301            tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
1302        }
1303    }
1304
1305    // Recover tasks stuck in in_review, external and internal in one sweep. Reset to NeedsReview
1306    // (not New) so the review agent re-triggers without clearing the routing state.
1307    //
1308    // This is the only periodic owner of the case where the review session is gone. The sync
1309    // sweep used to reset the same tasks after one minute, which beat a finished review's result
1310    // to the engine and dropped it as stale, and left this longer limit unreachable
1311    let in_review = match store.list_by_status(repo, TaskStatus::InReview).await {
1312        Ok(v) => v,
1313        Err(e) => {
1314            tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
1315            vec![]
1316        }
1317    };
1318    // A review agent ends its tmux session before its result reaches the engine, so the
1319    // no-session limit for a review has to be longer than for a task agent. The floor in
1320    // `EngineConfig::from_config` keeps it above the review agent's own timeout
1321    let in_review_config = engine::EngineConfig {
1322        no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1323        ..config.clone()
1324    };
1325    for task in &in_review {
1326        let Some(task_id) = task.external_id.clone() else {
1327            tracing::warn!(
1328                store_id = task.id,
1329                "in_review task has no external id, skipping stuck recovery"
1330            );
1331            continue;
1332        };
1333
1334        // The dispatch flow owns a task while it holds the guard
1335        if dispatching.contains_key(&format!("{repo}/{task_id}")) {
1336            tracing::debug!(
1337                task_id,
1338                "task locked by dispatch flow, skipping stuck check"
1339            );
1340            continue;
1341        }
1342
1343        // A recorded decision means the review finished and the task waits on the merge, not on
1344        // a review agent
1345        let (entered_at, decided) = in_review_stuck_entry(store, task.id, &task.updated_at).await;
1346        if decided {
1347            tracing::debug!(
1348                task_id,
1349                "in_review task already has a review decision, waiting on merge"
1350            );
1351            continue;
1352        }
1353
1354        let review_task_id = format!("{task_id}-review");
1355        let Some(timing) = stuck_task_timing_from_map(
1356            tmux,
1357            repo,
1358            &review_task_id,
1359            &entered_at,
1360            &in_review_config,
1361            "cannot parse in_review timestamp, skipping stuck in_review check",
1362            session_map,
1363        ) else {
1364            continue;
1365        };
1366
1367        tracing::warn!(
1368            task_id,
1369            age_mins = timing.age.num_minutes(),
1370            threshold_mins = timing.threshold / 60,
1371            has_session = timing.has_session,
1372            "recovering stuck in_review task — resetting to needs_review"
1373        );
1374
1375        match task_manager
1376            .update_task_status_if(
1377                &ExternalId(task_id.clone()),
1378                Status::NeedsReview,
1379                Status::InReview,
1380            )
1381            .await
1382        {
1383            Err(e) => {
1384                tracing::warn!(task_id, ?e, "failed to reset stuck in_review task status");
1385                continue;
1386            }
1387            Ok(false) => {
1388                tracing::debug!(
1389                    task_id,
1390                    "stuck in_review reset skipped — task already transitioned"
1391                );
1392                continue;
1393            }
1394            Ok(true) => {}
1395        }
1396
1397        // A dead or hung session is an infrastructure event, a tmux crash or a restart, not an
1398        // agent parse failure. Keeping the counter would spend the next cycle's quota
1399        if let Err(e) = store
1400            .set_fields(task.id, &[("review_agent_failures", serde_json::json!(0))])
1401            .await
1402        {
1403            tracing::warn!(task_id, ?e, "failed to reset review_agent_failures");
1404        }
1405
1406        finalize_open_runs_for_recovery(
1407            store,
1408            task.id,
1409            if timing.has_session {
1410                "stuck-task recovery: in_review session killed"
1411            } else {
1412                "stuck-task recovery: in_review no session found"
1413            },
1414        )
1415        .await;
1416
1417        let details = serde_json::json!({
1418            "failure_reason": "stuck_in_review",
1419            "age_minutes": timing.age.num_minutes(),
1420            "had_session": timing.has_session,
1421        });
1422        store::store_log_activity(
1423            &Some(Arc::clone(store)),
1424            repo,
1425            &task_id,
1426            "rerouted",
1427            Some("in_review"),
1428            Some("needs_review"),
1429            None::<&str>,
1430            None::<&str>,
1431            Some(&details),
1432        )
1433        .await;
1434
1435        // An internal task has no issue to comment on
1436        if task.origin == "internal" {
1437            continue;
1438        }
1439
1440        if let Err(e) = backend
1441            .post_comment(
1442                &ExternalId(task_id.clone()),
1443                &format!(
1444                    "[{}] recovered: stuck in_review — {} after {}m, resetting to needs_review{}",
1445                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1446                    if timing.has_session {
1447                        "review session hung"
1448                    } else {
1449                        "no session found"
1450                    },
1451                    timing.age.num_minutes(),
1452                    engine::orch_footer()
1453                ),
1454            )
1455            .await
1456        {
1457            tracing::warn!(
1458                task_id,
1459                ?e,
1460                "failed to post stuck in_review recovery comment"
1461            );
1462        }
1463    }
1464
1465    Ok(())
1466}
1467
1468/// External tasks stuck in_progress in a repo no project ticks, such as a project removed from
1469/// the config. Store-wide, so `tick_all` runs it once per tick rather than once per project
1470pub(crate) async fn recover_untracked_repo_stuck_tasks(
1471    tmux: &Arc<TmuxManager>,
1472    config: &EngineConfig,
1473    store: &Arc<TaskStore>,
1474    session_map: &HashMap<String, bool>,
1475    dispatching: &Arc<DashMap<String, String>>,
1476    active_repos: &HashSet<String>,
1477) {
1478    let all_external_in_progress = match store
1479        .list_all_by_status_global(TaskStatus::InProgress)
1480        .await
1481    {
1482        Ok(tasks) => tasks,
1483        Err(e) => {
1484            tracing::warn!(
1485                ?e,
1486                "failed to list global in_progress tasks for cross-repo stuck recovery"
1487            );
1488            vec![]
1489        }
1490    };
1491    for task in all_external_in_progress
1492        .iter()
1493        .filter(|t| t.origin != "internal" && !active_repos.contains(&t.repo))
1494    {
1495        let external_id = task
1496            .external_id
1497            .clone()
1498            .unwrap_or_else(|| task.id.to_string());
1499        let Some(timing) = stuck_task_timing_from_map(
1500            tmux,
1501            &task.repo,
1502            &external_id,
1503            &task.updated_at,
1504            config,
1505            "cannot parse updated_at, skipping cross-repo stuck-task check",
1506            session_map,
1507        ) else {
1508            continue;
1509        };
1510
1511        // See the comment on the in-progress loop in `tick_recover_stuck_tasks`: a dead session with a stale
1512        // `updated_at` also matches the tail end of a run that just finished successfully.
1513        // Skip the reclaim while the runner's dispatch guard for this task is still held.
1514        if !timing.has_session {
1515            let dispatch_key = format!("{}/{external_id}", task.repo);
1516            if dispatching.contains_key(&dispatch_key) {
1517                // See the same promotion note on the internal-task loop in `tick_recover_stuck_tasks`: this was
1518                // debug! (silently dropped under the default "orch=info" filter), leaving
1519                // every prior reclaim-race occurrence undiagnosable from logs alone.
1520                tracing::info!(
1521                    task_id = task.id,
1522                    repo = %task.repo,
1523                    dispatch_key,
1524                    age_mins = timing.age.num_minutes(),
1525                    "skipping cross-repo stuck-task reclaim: runner still actively processing this task"
1526                );
1527                continue;
1528            }
1529        }
1530
1531        if timing.has_session {
1532            tracing::warn!(
1533                task_id = task.id,
1534                repo = %task.repo,
1535                age_mins = timing.age.num_minutes(),
1536                threshold_mins = timing.threshold / 60,
1537                "recovering cross-repo stuck task: timed out with active session → new"
1538            );
1539        } else {
1540            tracing::warn!(
1541                task_id = task.id,
1542                repo = %task.repo,
1543                dispatch_key = format!("{}/{external_id}", task.repo),
1544                age_mins = timing.age.num_minutes(),
1545                threshold_mins = timing.threshold / 60,
1546                "recovering cross-repo stuck task: no session found — reclaiming early → new"
1547            );
1548        }
1549
1550        if let Err(e) = store_set_by_id(
1551            Some(store),
1552            task.id,
1553            &[
1554                ("agent", serde_json::Value::Null),
1555                ("model", serde_json::Value::Null),
1556                ("route_attempts", serde_json::json!(0)),
1557            ],
1558        )
1559        .await
1560        {
1561            tracing::warn!(
1562                task_id = task.id,
1563                repo = %task.repo,
1564                ?e,
1565                "failed to clear routing fields for cross-repo stuck task — will retry next tick"
1566            );
1567            continue;
1568        }
1569
1570        finalize_open_runs_for_recovery(
1571            store,
1572            task.id,
1573            if timing.has_session {
1574                "stuck-task recovery: cross-repo active session killed"
1575            } else {
1576                "stuck-task recovery: cross-repo no session found"
1577            },
1578        )
1579        .await;
1580
1581        if let Err(e) = store.update_status(task.id, TaskStatus::New).await {
1582            tracing::warn!(
1583                task_id = task.id,
1584                repo = %task.repo,
1585                ?e,
1586                "failed to reset cross-repo stuck task status"
1587            );
1588            continue;
1589        }
1590
1591        let details = serde_json::json!({
1592            "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1593            "age_minutes": timing.age.num_minutes(),
1594            "had_session": timing.has_session,
1595            "scope": "cross_repo_global_sweep",
1596        });
1597        store::store_log_activity(
1598            &Some(Arc::clone(store)),
1599            &task.repo,
1600            &external_id,
1601            "rerouted",
1602            Some("in_progress"),
1603            Some("new"),
1604            None::<&str>,
1605            None::<&str>,
1606            Some(&details),
1607        )
1608        .await;
1609    }
1610}
1611
1612/**
1613 * The three store-wide recovery sweeps, run once per tick rather than once per project.
1614 *
1615 * Each one reads every repo, so a per-project call duplicates the work. The NeedsReview
1616 * sweep also reads `needs_review_refires` and then increments it, so concurrent per-project
1617 * passes all see the same count and four projects burn four of the five refire attempts in
1618 * one tick (#3864)
1619 **/
1620pub(crate) async fn tick_global_sweeps(
1621    task_manager: &Arc<TaskManager>,
1622    store: &Arc<TaskStore>,
1623    router: &Router,
1624    dispatching: &Arc<DashMap<String, String>>,
1625    auto_merge_in_flight: &Arc<DashSet<String>>,
1626) {
1627    // Global sweep for CI-failure blocked tasks from inactive or removed projects.
1628    // auto_unblock_blocked_tasks runs inside sync_tick and is scoped to the active repo.
1629    // Tasks blocked for projects no longer in orch config are never processed there.
1630    // This sweep covers all repos so stale CI-failure blocks are eventually resolved.
1631    if let Err(e) = sync::auto_unblock_ci_failure_blocked_tasks_global(
1632        task_manager,
1633        store,
1634        auto_merge_in_flight,
1635    )
1636    .await
1637    {
1638        tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
1639    }
1640
1641    // Re-fire stale NeedsReview tasks and escalate persistently-stuck ones to
1642    // Blocked, across all repos
1643    if let Err(e) = sync::refire_and_escalate_stale_needs_review_global(
1644        task_manager,
1645        store,
1646        router,
1647        dispatching,
1648    )
1649    .await
1650    {
1651        tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
1652    }
1653
1654    // Recover rebroadcast-blocked tasks once a review agent is routable, across
1655    // all repos
1656    if let Err(e) =
1657        sync::auto_recover_rebroadcast_blocked_tasks_global(router, task_manager, store).await
1658    {
1659        tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
1660    }
1661}
1662
1663/// Phase 3a of tick: route status:new tasks to an agent and transition them to status:routed.
1664pub(crate) async fn tick_route_tasks(
1665    backend: &Arc<dyn ExternalBackend>,
1666    task_manager: &Arc<TaskManager>,
1667    router: &mut Router,
1668    store: &Arc<TaskStore>,
1669    repo: &str,
1670    routing_quota: &mut RoutingTickQuota,
1671) -> anyhow::Result<()> {
1672    let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
1673    if let Err(err) = dependencies::apply_claim_gates(store).await {
1674        tracing::warn!(err = %err, "dependency claim gate failed");
1675    }
1676    if routing_quota.is_exhausted() {
1677        tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1678        return Ok(());
1679    }
1680
1681    // Global GitHub 5xx circuit breaker — skip routing during sustained GitHub outages
1682    // to avoid routing-heavy retry storms. Tasks will remain in 'new' status and be
1683    // retried when the circuit closes.
1684    if is_github_circuit_open() {
1685        let remaining = github_circuit_remaining_secs();
1686        tracing::info!(
1687            remaining_secs = remaining,
1688            "GitHub 5xx circuit breaker open — skipping routing phase"
1689        );
1690        return Ok(());
1691    }
1692    let new_tasks = task_manager.list_routable().await?;
1693    let routable: Vec<&ExternalTask> = new_tasks
1694        .iter()
1695        .filter(|t| !skips_agent(&t.labels))
1696        .collect();
1697
1698    // Pre-emptive health check: refresh degraded-agent flags once per tick,
1699    // not per-task (previously this ran inside route() causing N redundant
1700    // DB queries when routing N tasks in the same tick).
1701    if !routable.is_empty() {
1702        let start = Instant::now();
1703        router.refresh_health(store).await;
1704        tracing::debug!(
1705            duration_ms = start.elapsed().as_millis(),
1706            "router.refresh_health completed"
1707        );
1708    }
1709
1710    // Limit routing to the remaining global quota for this outer engine tick.
1711    for task in routable.into_iter().take(routing_quota.remaining()) {
1712        if !routing_quota.consume_one() {
1713            break;
1714        }
1715        let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1716
1717        let task_start = Instant::now();
1718        match router.route(task, store, repo).await {
1719            Ok(result) => {
1720                tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1721                // Store route result in store
1722                if let Err(e) = router
1723                    .store_route_result(&task.id.0, &result, store, repo)
1724                    .await
1725                {
1726                    tracing::warn!(
1727                        task_id = task.id.0,
1728                        ?e,
1729                        "failed to store route result; skipping Routed transition"
1730                    );
1731                    continue;
1732                }
1733
1734                if is_internal_id(&task.id.0) {
1735                    // Internal tasks: update DB status, skip GitHub label ops.
1736                    if let Err(e) = task_manager
1737                        .update_task_status(&task.id, Status::Routed)
1738                        .await
1739                    {
1740                        tracing::warn!(
1741                            task_id = task.id.0,
1742                            ?e,
1743                            "failed to set internal status:routed"
1744                        );
1745                        continue;
1746                    }
1747                } else {
1748                    // Fire-and-forget: label operations are cosmetic (routing already
1749                    // persisted in DB). Spawning them prevents blocking the tick loop
1750                    // on 6-9 sequential HTTP calls (~3-9s overhead per routed task).
1751                    let backend_clone = Arc::clone(backend);
1752                    let task_id_clone = task.id.clone();
1753                    let task_labels: Vec<String> = task.labels.clone();
1754                    let new_labels = {
1755                        let mut labels = vec![
1756                            format!("agent:{}", result.agent),
1757                            format!("complexity:{}", result.complexity),
1758                        ];
1759                        if let Some(ref model) = result.model {
1760                            labels.push(format!("model:{model}"));
1761                        }
1762                        labels
1763                    };
1764                    tokio::spawn(async move {
1765                        // Remove old agent/complexity/model labels
1766                        for label in &task_labels {
1767                            if (label.starts_with("agent:")
1768                                || label.starts_with("complexity:")
1769                                || label.starts_with("model:"))
1770                                && let Err(e) =
1771                                    backend_clone.remove_label(&task_id_clone, label).await
1772                            {
1773                                tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1774                            }
1775                        }
1776                        // Add new labels
1777                        if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1778                        {
1779                            tracing::warn!(
1780                                task_id = task_id_clone.0,
1781                                ?e,
1782                                "failed to set routing labels"
1783                            );
1784                        }
1785                    });
1786
1787                    // Transition to routed
1788                    if let Err(e) = task_manager
1789                        .update_task_status(&task.id, Status::Routed)
1790                        .await
1791                    {
1792                        tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1793                        continue;
1794                    }
1795                }
1796
1797                // Dual-write: upsert task + store route result in SQLite
1798                match store.ensure_external_task(repo, task).await {
1799                    Ok(store_id) => {
1800                        let profile_json =
1801                            serde_json::to_string(&result.profile).unwrap_or_default();
1802                        let skills_json =
1803                            serde_json::to_string(&result.selected_skills).unwrap_or_default();
1804                        if let Err(e) = store
1805                            .store_route(&crate::store::StoreRoute {
1806                                id: store_id,
1807                                agent: &result.agent,
1808                                model: result.model.as_deref(),
1809                                complexity: &result.complexity,
1810                                estimate: result.estimate,
1811                                reason: &result.reason,
1812                                profile: &profile_json,
1813                                skills: &skills_json,
1814                            })
1815                            .await
1816                        {
1817                            tracing::debug!(
1818                                task_id = task.id.0,
1819                                ?e,
1820                                "dual-write: store_route failed"
1821                            );
1822                        }
1823                        if let Err(e) = store.update_status(store_id, TaskStatus::Routed).await {
1824                            tracing::debug!(
1825                                task_id = task.id.0,
1826                                ?e,
1827                                "dual-write: update_status failed"
1828                            );
1829                        }
1830                        // Sync the estimate to GitHub Projects if the router assigned one.
1831                        // Fire-and-forget: do NOT await inline — GraphQL mutations can take
1832                        // up to 30 s and would block the entire tick loop on every routed task.
1833                        if result.estimate > 0 {
1834                            let backend_clone = Arc::clone(backend);
1835                            let task_id = task.id.clone();
1836                            let estimate = result.estimate;
1837                            tokio::spawn(async move {
1838                                if let Err(e) = backend_clone
1839                                    .sync_estimate_to_project(&task_id, estimate)
1840                                    .await
1841                                {
1842                                    tracing::debug!(
1843                                        task_id = task_id.0,
1844                                        estimate,
1845                                        err = %e,
1846                                        "dual-write: estimate project sync failed"
1847                                    );
1848                                }
1849                            });
1850                        }
1851                    }
1852                    Err(e) => {
1853                        tracing::debug!(
1854                            task_id = task.id.0,
1855                            ?e,
1856                            "dual-write: ensure_external_task failed"
1857                        );
1858                    }
1859                }
1860
1861                if let Some(ref warning) = result.warning {
1862                    tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1863                }
1864
1865                tracing::info!(
1866                    task_id = task.id.0,
1867                    agent = %result.agent,
1868                    complexity = %result.complexity,
1869                    reason = %result.reason,
1870                    "task routed"
1871                );
1872            }
1873            Err(e) => {
1874                if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1875                    tracing::error!(
1876                        task_id = %task.id.0,
1877                        scope = %cooled.scope(),
1878                        remaining_secs = ?cooled.remaining_secs(),
1879                        degraded = ?cooled.degraded(),
1880                        "ALL AGENTS COOLED — no agent available to route this task. \
1881                         Task stays in 'new' and will be retried on every tick. \
1882                         Check `orch cooldown list` to see what is blocking dispatch."
1883                    );
1884                    continue;
1885                }
1886                if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
1887                    tracing::info!(
1888                        task_id = %task.id.0,
1889                        blockers = %waiting.blockers.join(", "),
1890                        "task is waiting on a dependency"
1891                    );
1892                    continue;
1893                }
1894                tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1895            }
1896        }
1897    }
1898    Ok(())
1899}
1900
1901/// Global routing sweep: route status:new tasks from inactive or removed repos.
1902///
1903/// `tick_route_tasks` is scoped to the active repo per-tick and never processes
1904/// tasks whose `repo` field differs from the current project. When a project is
1905/// removed from the orch config, its tasks accumulate in `new` indefinitely
1906/// because no tick ever runs for that repo. This sweep covers all repos so
1907/// orphaned new tasks are eventually routed.
1908///
1909/// `active_repos` must be the full set of currently-configured project repos,
1910/// not just the repo of the tick that called this — mirroring
1911/// `dispatch_routed_tasks_global` (see its doc comment for the "racing the real
1912/// per-repo dispatch" failure mode this guards against). Filtering on
1913/// `t.repo != current_repo` alone (the previous behavior) made every *other*
1914/// active repo's new tasks look "orphaned" too and routed them a tick early,
1915/// through the wrong repo's tick.
1916pub(crate) async fn route_new_tasks_global(
1917    router: &mut Router,
1918    store: &Arc<TaskStore>,
1919    active_repos: &HashSet<String>,
1920    routing_quota: &mut RoutingTickQuota,
1921) -> anyhow::Result<()> {
1922    if routing_quota.is_exhausted() {
1923        tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1924        return Ok(());
1925    }
1926
1927    let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1928    let orphaned: Vec<&Task> = all_new
1929        .iter()
1930        .filter(|t| !active_repos.contains(&t.repo))
1931        .filter(|t| !skips_agent(&t.labels))
1932        .collect();
1933
1934    if orphaned.is_empty() {
1935        return Ok(());
1936    }
1937
1938    tracing::info!(
1939        count = orphaned.len(),
1940        "global routing sweep: found new tasks from inactive repos"
1941    );
1942
1943    router.refresh_health(store).await;
1944
1945    for task in orphaned.into_iter().take(routing_quota.remaining()) {
1946        if !routing_quota.consume_one() {
1947            break;
1948        }
1949        let ext_task = tasks::store_task_to_external(task);
1950        let task_start = Instant::now();
1951        match router.route(&ext_task, store, &task.repo).await {
1952            Ok(result) => {
1953                tracing::debug!(
1954                    task_id = %ext_task.id.0,
1955                    repo = %task.repo,
1956                    duration_ms = task_start.elapsed().as_millis(),
1957                    "global sweep: route completed"
1958                );
1959                let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1960                let skills_json =
1961                    serde_json::to_string(&result.selected_skills).unwrap_or_default();
1962                if let Err(e) = store
1963                    .store_route(&StoreRoute {
1964                        id: task.id,
1965                        agent: &result.agent,
1966                        model: result.model.as_deref(),
1967                        complexity: &result.complexity,
1968                        estimate: result.estimate,
1969                        reason: &result.reason,
1970                        profile: &profile_json,
1971                        skills: &skills_json,
1972                    })
1973                    .await
1974                {
1975                    tracing::warn!(
1976                        task_id = %ext_task.id.0,
1977                        repo = %task.repo,
1978                        err = %e,
1979                        "global sweep: failed to store route result; skipping status update"
1980                    );
1981                    continue;
1982                }
1983                match store.update_status(task.id, TaskStatus::Routed).await {
1984                    Err(e) => {
1985                        tracing::warn!(
1986                            task_id = %ext_task.id.0,
1987                            repo = %task.repo,
1988                            err = %e,
1989                            "global sweep: failed to set status:routed"
1990                        );
1991                    }
1992                    _ => {
1993                        tracing::info!(
1994                            task_id = %ext_task.id.0,
1995                            repo = %task.repo,
1996                            agent = %result.agent,
1997                            complexity = %result.complexity,
1998                            "global sweep: task routed"
1999                        );
2000                    }
2001                }
2002                if let Some(ref warning) = result.warning {
2003                    tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
2004                }
2005            }
2006            Err(e) => {
2007                if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
2008                    tracing::error!(
2009                        task_id = %ext_task.id.0,
2010                        repo = %task.repo,
2011                        scope = %cooled.scope(),
2012                        remaining_secs = ?cooled.remaining_secs(),
2013                        degraded = ?cooled.degraded(),
2014                        "global sweep: ALL AGENTS COOLED — task stays in 'new'"
2015                    );
2016                    continue;
2017                }
2018                if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
2019                    tracing::info!(
2020                        task_id = %ext_task.id.0,
2021                        repo = %task.repo,
2022                        blockers = %waiting.blockers.join(", "),
2023                        "global sweep: task is waiting on a dependency"
2024                    );
2025                    continue;
2026                }
2027                tracing::error!(
2028                    task_id = %ext_task.id.0,
2029                    repo = %task.repo,
2030                    err = ?e,
2031                    "global sweep: routing failed, skipping task"
2032                );
2033            }
2034        }
2035    }
2036
2037    Ok(())
2038}
2039
2040/// Phase 3b of tick: spawn agents for all status:routed tasks up to the parallel limit.
2041#[derive(Debug, Clone, Copy)]
2042pub(crate) struct DispatchMode {
2043    pub(crate) is_degraded: bool,
2044    pub(crate) healthy_agents: usize,
2045    pub(crate) threshold: usize,
2046}
2047
2048pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
2049    let threshold = router_config::min_healthy_agents_threshold();
2050    let healthy_agents = router.healthy_agent_count("simple");
2051    let is_degraded = router.is_degraded(threshold);
2052    DispatchMode {
2053        is_degraded,
2054        healthy_agents,
2055        threshold,
2056    }
2057}
2058
2059/// Phase 3b of tick: spawn agents for all status:routed tasks up to the parallel limit.
2060#[expect(
2061    clippy::too_many_arguments,
2062    reason = "each argument is engine state the tick passes down"
2063)]
2064#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
2065pub(crate) async fn tick_dispatch_tasks(
2066    backend: &Arc<dyn ExternalBackend>,
2067    tmux: &Arc<TmuxManager>,
2068    repo: &str,
2069    runner: &Arc<TaskRunner>,
2070    capture: &Arc<CaptureService>,
2071    semaphore: &Arc<Semaphore>,
2072    task_manager: &Arc<TaskManager>,
2073    weight_tx: &mpsc::Sender<WeightSignal>,
2074    dispatch_mode: DispatchMode,
2075    dispatching: &Arc<DashMap<String, String>>,
2076    store: &Arc<TaskStore>,
2077    session_map: &HashMap<String, bool>,
2078) -> anyhow::Result<()> {
2079    // Phase 3a already drops no-agent and owner:human, this filter stays as defense in depth
2080    let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
2081
2082    // Also include internal tasks in Routed status.
2083    let internal_routed = task_manager
2084        .list_internal_by_status(DbStatus::Routed)
2085        .await?;
2086    routed_tasks.extend(internal_routed);
2087
2088    // Filter and collect owned tasks to avoid lifetime issues
2089    let dispatchable: Vec<ExternalTask> = routed_tasks
2090        .into_iter()
2091        .filter(|t| !skips_agent(&t.labels))
2092        .collect();
2093
2094    dispatch_tasks_for_repo(
2095        backend,
2096        tmux,
2097        repo,
2098        runner,
2099        capture,
2100        semaphore,
2101        task_manager,
2102        weight_tx,
2103        dispatch_mode,
2104        dispatching,
2105        store,
2106        session_map,
2107        dispatchable,
2108    )
2109    .await
2110}
2111
2112/// Dispatch a pre-fetched list of routed tasks for a single repo.
2113///
2114/// Shared by `tick_dispatch_tasks` (active-repo, per-tick dispatch) and
2115/// `dispatch_routed_tasks_global` (inactive-repo sweep) so both paths spawn
2116/// agents through the exact same worktree/tmux/runner logic.
2117#[expect(
2118    clippy::too_many_arguments,
2119    reason = "each argument is engine state the tick passes down"
2120)]
2121async fn dispatch_tasks_for_repo(
2122    backend: &Arc<dyn ExternalBackend>,
2123    tmux: &Arc<TmuxManager>,
2124    repo: &str,
2125    runner: &Arc<TaskRunner>,
2126    capture: &Arc<CaptureService>,
2127    semaphore: &Arc<Semaphore>,
2128    task_manager: &Arc<TaskManager>,
2129    weight_tx: &mpsc::Sender<WeightSignal>,
2130    dispatch_mode: DispatchMode,
2131    dispatching: &Arc<DashMap<String, String>>,
2132    store: &Arc<TaskStore>,
2133    session_map: &HashMap<String, bool>,
2134    dispatchable: Vec<ExternalTask>,
2135) -> anyhow::Result<()> {
2136    if dispatchable.is_empty() {
2137        tracing::debug!(count = 0, "dispatchable tasks found");
2138        return Ok(());
2139    }
2140
2141    tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2142    if dispatch_mode.is_degraded {
2143        tracing::warn!(
2144            healthy_agents = dispatch_mode.healthy_agents,
2145            threshold = dispatch_mode.threshold,
2146            "degraded mode: using sequential dispatch"
2147        );
2148    }
2149
2150    let sequential_delay = if dispatch_mode.is_degraded {
2151        router_config::sequential_dispatch_delay_ms()
2152    } else {
2153        0
2154    };
2155
2156    let mut ranked: Vec<(Priority, ExternalTask)> = Vec::with_capacity(dispatchable.len());
2157    for task in dispatchable {
2158        let has_pr = match store.resolve_id_by_external(repo, &task.id.0).await {
2159            Ok(Some(id)) => store.get(id).await.is_ok_and(|t| t.pr_number.is_some()),
2160            _ => false,
2161        };
2162        ranked.push((Priority::from_labels(&task.labels).with_pr(has_pr), task));
2163    }
2164    priority::sort_by_priority(&mut ranked, |t| t.0);
2165
2166    for (idx, (priority, task)) in ranked.into_iter().enumerate() {
2167        if dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2168            continue;
2169        }
2170        // In degraded mode, add delay between dispatches to pace the system
2171        if idx > 0 && dispatch_mode.is_degraded {
2172            let delay_ms = sequential_delay;
2173            tracing::debug!(
2174                task_id = task.id.0,
2175                delay_ms = delay_ms,
2176                "sequential dispatch: waiting before dispatch"
2177            );
2178            time::sleep(Duration::from_millis(delay_ms)).await;
2179        }
2180        // In-memory guard: prevents double-dispatch due to GitHub API eventual consistency.
2181        // After update_status(InProgress), the label removal fires a webhook that can
2182        // trigger an immediate tick. GitHub's search index may not yet reflect the label
2183        // change, so list_by_status(Routed) can still return this task. The tmux session
2184        // does not exist until the runner completes worktree setup (~10s later), so the
2185        // session_exists check alone is insufficient.
2186        let dispatch_key = format!("{}/{}", repo, task.id.0);
2187        // Atomically claim the dispatch slot.  DashMap lets us distinguish:
2188        //   - same task already in-flight (expected): log debug and skip.
2189        //   - different task holds the same key (should never happen): log warning and skip.
2190        // A plain DashSet::insert would return false for both cases without distinction.
2191        {
2192            match dispatching.entry(dispatch_key.clone()) {
2193                Entry::Occupied(existing) => {
2194                    let existing_id = existing.get().clone();
2195                    drop(existing); // release shard lock before logging
2196                    if existing_id == task.id.0 {
2197                        tracing::debug!(
2198                            task_id = task.id.0,
2199                            "task already dispatching, skipping duplicate"
2200                        );
2201                    } else {
2202                        tracing::warn!(
2203                            task_id = task.id.0,
2204                            existing_task_id = existing_id,
2205                            dispatch_key,
2206                            "dispatch key collision: unexpected task already holds this key"
2207                        );
2208                    }
2209                    continue;
2210                }
2211                Entry::Vacant(slot) => {
2212                    slot.insert(task.id.0.clone());
2213                    // Promoted from debug! to info! (issue #3526): paired with the
2214                    // "dispatch guard released" line in dispatch_guard.rs, this is the
2215                    // only direct evidence of how long a dispatch guard was actually held
2216                    // for a given key. Both were debug!, silently dropped under the
2217                    // default "orch=info" filter (main.rs) — see the reclaim-skip log
2218                    // promotions above for the full rationale.
2219                    tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2220                }
2221            }
2222        }
2223        // RAII guard — removes dispatch_key on drop even if the spawned task panics.
2224        let dispatch_guard = DispatchGuard::new(Arc::clone(dispatching), dispatch_key.clone());
2225        let task_id = task.id.0.clone();
2226
2227        // Load routing result from store (stored during Phase 3a). Loaded before the
2228        // tmux/semaphore/in_progress steps so a stale cooldown can bail out cheaply.
2229        let route_result = match get_route_result(store, repo, &task_id).await {
2230            Ok(r) => Some(r),
2231            Err(RouteResultError::NoAgent { .. }) => {
2232                tracing::warn!(
2233                    task_id,
2234                    "routed task missing agent — resetting to new for re-routing (#1604)"
2235                );
2236                if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2237                    tracing::error!(task_id, error = %e2, "failed to reset task to new");
2238                }
2239                continue; // dispatch_guard drops here, removing the key
2240            }
2241            Err(e) => {
2242                tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2243                None
2244            }
2245        };
2246
2247        // Re-check the assigned agent/model against the cooldown store: routing and
2248        // dispatch are decoupled by ticks, and a failure recorded after this task was
2249        // routed but before it dispatched would otherwise burn a full run on an
2250        // already-cooled agent/model. Leave the task in Routed so the next tick's
2251        // dispatch pass re-evaluates it — no new per-task defer state (see #3243).
2252        if let Some(route) = route_result.as_ref() {
2253            let agent_cooled = is_agent_in_cooldown(&route.agent);
2254            let model_cooled = route
2255                .model
2256                .as_deref()
2257                .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2258            if agent_cooled || model_cooled {
2259                tracing::warn!(
2260                    task_id,
2261                    agent = route.agent,
2262                    model = route.model.as_deref().unwrap_or(""),
2263                    "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2264                );
2265                continue; // dispatch_guard drops here, removing the key
2266            }
2267        }
2268
2269        // Check if already running (has active session)
2270        let session_name = tmux.session_name(repo, &task_id);
2271        if tmux
2272            .session_blocks_dispatch_from_map(&session_name, session_map)
2273            .await
2274        {
2275            tracing::warn!(
2276                task_id,
2277                session_name,
2278                "task has existing tmux session, skipping dispatch"
2279            );
2280            continue; // dispatch_guard drops here, removing the key
2281        }
2282
2283        // Try to acquire a slot
2284        let Some(permit) = priority::acquire_dispatch_slot(semaphore, priority) else {
2285            tracing::debug!("all parallel slots busy, skipping remaining tasks");
2286            break; // dispatch_guard drops here, removing the key
2287        };
2288
2289        // Mark in_progress BEFORE spawning to prevent double dispatch.
2290        let set_in_progress_result = task_manager
2291            .update_task_status(&task.id, Status::InProgress)
2292            .await;
2293        if let Err(e) = set_in_progress_result {
2294            tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2295            drop(permit);
2296            continue; // dispatch_guard drops here, removing the key
2297        }
2298        tracing::info!(task_id, "dispatching task");
2299
2300        // Register session for capture
2301        capture
2302            .register_session(repo, &task_id, &session_name)
2303            .await;
2304
2305        // Dispatch task
2306        let runner = Arc::clone(runner);
2307        let backend = Arc::clone(backend);
2308        let tmux = Arc::clone(tmux);
2309        let capture = Arc::clone(capture);
2310        let task_id_for_cleanup = task_id.clone();
2311        let task_owned = task;
2312        let weight_tx = weight_tx.clone();
2313        let repo_owned = repo.to_string();
2314        let task_manager_for_spawn = Arc::clone(task_manager);
2315        let store_for_spawn = Arc::clone(store);
2316
2317        let repo_ctx = repo_owned.clone();
2318        tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2319            let _dispatch_guard = dispatch_guard; // released on drop (normal or panic)
2320            let dispatch_start = Instant::now();
2321            match runner
2322                .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2323                .await
2324            {
2325                Ok(signal) => {
2326                    tracing::info!(task_id, "task runner completed");
2327
2328                    let duration = dispatch_start.elapsed().as_secs_f64();
2329
2330                    // Derive a display status from the weight signal.
2331                    // When review agent is enabled, successful tasks go through
2332                    // review before being marked done — UNLESS the response
2333                    // handler already marked the task done (no-op tasks with
2334                    // no PR and no commits should not enter the review cycle).
2335                    let enable_review = config::get("workflow.enable_review_agent")
2336                        .map_or(true, |v| v != "false");
2337                    // Only a successful run can reach the no-PR shortcut, so only then read the PR.
2338                    let has_pr = if enable_review && matches!(signal, WeightSignal::Success { .. })
2339                    {
2340                        match store_for_spawn.resolve_task_id(&repo_owned, &task_id).await {
2341                            Ok(Some(store_id)) => match store_for_spawn.get(store_id).await {
2342                                Ok(t) => t.pr_number.is_some(),
2343                                Err(e) => {
2344                                    tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2345                                    true
2346                                }
2347                            },
2348                            Ok(None) => false,
2349                            Err(e) => {
2350                                tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2351                                true
2352                            }
2353                        }
2354                    } else {
2355                        false
2356                    };
2357                    let display_status = display_status_for(&signal, enable_review, has_pr);
2358
2359                    if display_status == "done" {
2360                        tracing::info!(
2361                            task_id,
2362                            "marking done: the run succeeded with no PR to review"
2363                        );
2364                    }
2365                    tracing::info!(task_id, display_status, "task run finished, applying status");
2366
2367                    // Send weight signal back to the router
2368                    let _ = weight_tx.send(signal).await;
2369
2370                    // All tasks: if needs_review, trigger the review agent.
2371                    // Status updates go through task_manager so internal tasks
2372                    // hit SQLite while external tasks hit GitHub labels.
2373                    if display_status == "needs_review" {
2374                        // Transition to NeedsReview — emits event so the review subscriber
2375                        // fires with the correct duration before the review agent starts.
2376                        if let Err(e) = task_manager_for_spawn
2377                            .update_task_status_with_duration(
2378                                &ExternalId(task_id.clone()),
2379                                Status::NeedsReview,
2380                                Some(duration),
2381                            )
2382                            .await
2383                        {
2384                            tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2385                        }
2386                        // Review agent dispatch is handled by the event-driven subscriber.
2387                    } else {
2388                        // done, blocked, or new (rate-limited): update status directly.
2389                        let final_status = match display_status {
2390                            "done" => Status::Done,
2391                            "blocked" => Status::Blocked,
2392                            _ => Status::New,
2393                        };
2394                        if let Err(e) = task_manager_for_spawn
2395                            .update_task_status_with_duration(
2396                                &ExternalId(task_id.clone()),
2397                                final_status,
2398                                Some(duration),
2399                            )
2400                            .await
2401                        {
2402                            tracing::warn!(task_id, ?e, "failed to update task status after completion");
2403                        }
2404                    }
2405                }
2406                Err(e) => {
2407                    tracing::error!(task_id, ?e, "task runner failed");
2408                    let duration = dispatch_start.elapsed().as_secs_f64();
2409                    if is_internal_id(&task_id) {
2410                        // Internal tasks have no GitHub issue to comment on.
2411                        if let Err(ue) = task_manager_for_spawn
2412                            .update_task_status_with_duration(
2413                                &ExternalId(task_id.clone()),
2414                                Status::NeedsReview,
2415                                Some(duration),
2416                            )
2417                            .await
2418                        {
2419                            tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2420                        }
2421                    } else {
2422                        // Post comment (best-effort)
2423                        if let Err(comment_err) = backend
2424                            .post_comment(
2425                                &ExternalId(task_id.clone()),
2426                                &format!(
2427                                    "[{}] error: task runner failed: {e}{}",
2428                                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2429                                    engine::orch_footer()
2430                                ),
2431                            )
2432                            .await
2433                        {
2434                            tracing::warn!(
2435                                task_id,
2436                                ?comment_err,
2437                                "failed to post error comment to GitHub"
2438                            );
2439                        }
2440                        // Update status immediately to NeedsReview so external tasks
2441                        // don't remain stuck in InProgress until the no-session timeout.
2442                        // The notify subscriber will send the channel notification with duration.
2443                        if let Err(ue) = task_manager_for_spawn
2444                            .update_task_status_with_duration(
2445                                &ExternalId(task_id.clone()),
2446                                Status::NeedsReview,
2447                                Some(duration),
2448                            )
2449                            .await
2450                        {
2451                            tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2452                        }
2453                    }
2454                }
2455            }
2456
2457            // Unregister session from capture
2458            capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2459
2460            // Release the semaphore permit
2461            drop(permit);
2462            // _dispatch_guard drops here, removing the key from the dispatching set.
2463        }));
2464    }
2465    Ok(())
2466}
2467
2468fn skips_agent(labels: &[String]) -> bool {
2469    labels
2470        .iter()
2471        .any(|label| label == "no-agent" || label == "owner:human")
2472}
2473
2474/// Filter routed tasks down to those belonging to a repo outside `active_repos`,
2475/// drop `no-agent` and `owner:human` tasks, and group the rest by repo.
2476///
2477/// Pure/side-effect-free so it can be unit tested without touching real tmux
2478/// sessions or spawning a runner — see `dispatch_routed_tasks_global`, which is
2479/// the only caller and does the actual (side-effectful) dispatch.
2480fn group_orphaned_routed_tasks(
2481    all_routed: Vec<Task>,
2482    active_repos: &HashSet<String>,
2483) -> HashMap<String, Vec<Task>> {
2484    let mut by_repo: HashMap<String, Vec<Task>> = HashMap::new();
2485    for task in all_routed
2486        .into_iter()
2487        .filter(|t| !active_repos.contains(&t.repo))
2488        .filter(|t| !skips_agent(&t.labels))
2489    {
2490        by_repo.entry(task.repo.clone()).or_default().push(task);
2491    }
2492    by_repo
2493}
2494
2495/// Global dispatch sweep: dispatch status:routed tasks from inactive or removed repos.
2496///
2497/// `tick_dispatch_tasks` is scoped to the active repo per-tick (its `task_manager`
2498/// only queries that repo's tasks) and never processes tasks whose `repo` field
2499/// differs from the current project. When a project is removed from the orch
2500/// config, tasks that reach `routed` for it — including ones routed by the
2501/// `route_new_tasks_global` sweep — have no code path left to dispatch them, so
2502/// they stay `routed` forever. This sweep covers all repos so orphaned routed
2503/// tasks are eventually dispatched.
2504///
2505/// `active_repos` must be the full set of currently-configured project repos,
2506/// not just one project's repo; passing only that project's own repo would make
2507/// every other active repo's routed tasks look "orphaned" too and dispatch them
2508/// a second time through this fallback path, racing the real per-repo dispatch.
2509///
2510/// `TaskRunner::resolve_project_dir` already falls back to the deterministic
2511/// `~/.orch/projects/<owner>/<repo>.git` bare-clone convention when a repo is
2512/// not listed in the active config, so dispatch works without a `ProjectEngine`.
2513#[expect(
2514    clippy::too_many_arguments,
2515    reason = "each argument is engine state the tick passes down"
2516)]
2517pub(crate) async fn dispatch_routed_tasks_global(
2518    tmux: &Arc<TmuxManager>,
2519    capture: &Arc<CaptureService>,
2520    semaphore: &Arc<Semaphore>,
2521    weight_tx: &mpsc::Sender<WeightSignal>,
2522    dispatch_mode: DispatchMode,
2523    dispatching: &Arc<DashMap<String, String>>,
2524    store: &Arc<TaskStore>,
2525    session_map: &HashMap<String, bool>,
2526    active_repos: &HashSet<String>,
2527) -> anyhow::Result<()> {
2528    let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2529    let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2530
2531    if by_repo.is_empty() {
2532        return Ok(());
2533    }
2534
2535    tracing::info!(
2536        repos = by_repo.len(),
2537        "global dispatch sweep: found routed tasks from inactive repos"
2538    );
2539
2540    for (repo, tasks) in by_repo {
2541        let backend: Arc<dyn ExternalBackend> = match GitHubBackend::new(repo.clone()) {
2542            Ok(b) => Arc::new(b),
2543            Err(e) => {
2544                tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2545                continue;
2546            }
2547        };
2548        let task_manager = Arc::new(TaskManager::with_store(
2549            Arc::clone(&backend),
2550            Arc::clone(store),
2551            repo.clone(),
2552        ));
2553        let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(Arc::clone(store)));
2554        let dispatchable: Vec<ExternalTask> =
2555            tasks.iter().map(tasks::store_task_to_external).collect();
2556
2557        if let Err(e) = dispatch_tasks_for_repo(
2558            &backend,
2559            tmux,
2560            &repo,
2561            &runner,
2562            capture,
2563            semaphore,
2564            &task_manager,
2565            weight_tx,
2566            dispatch_mode,
2567            dispatching,
2568            store,
2569            session_map,
2570            dispatchable,
2571        )
2572        .await
2573        {
2574            tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2575        }
2576    }
2577
2578    Ok(())
2579}
2580
2581/// Phase 4 of tick: unblock parent tasks whose sub-issues are all done.
2582pub(crate) async fn tick_unblock_parents(
2583    backend: &Arc<dyn ExternalBackend>,
2584    task_manager: &Arc<TaskManager>,
2585    store: &Arc<TaskStore>,
2586    repo: &str,
2587) -> anyhow::Result<()> {
2588    let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2589
2590    // Pre-filter: skip tasks that are blocked for a known reason (e.g. CI failure,
2591    // escalation). These are not waiting on children, so there is no point making
2592    // a GraphQL call for them.
2593    let store_blocked = store.list_by_status(repo, TaskStatus::Blocked).await?;
2594    let has_block_reason: HashSet<String> = store_blocked
2595        .iter()
2596        .filter(|t| t.block_reason.is_some())
2597        .filter_map(|t| t.external_id.clone())
2598        .collect();
2599
2600    // Spawn a concurrent task for each candidate blocked task.
2601    // Previously these ran sequentially; get_sub_issues is a GraphQL call taking 1-3 s each,
2602    // and with 4 blocked tasks that was 4-12 s of sequential latency every tick.
2603    let handles: Vec<_> = blocked
2604        .into_iter()
2605        .filter(|task| {
2606            if has_block_reason.contains(&task.id.0) {
2607                tracing::debug!(
2608                    task_id = task.id.0,
2609                    "skipping blocked task with known block_reason"
2610                );
2611                false
2612            } else {
2613                true
2614            }
2615        })
2616        .map(|task| {
2617            let backend_clone = Arc::clone(backend);
2618            let task_manager_clone = Arc::clone(task_manager);
2619            let store_clone = Arc::clone(store);
2620            let repo_owned = repo.to_string();
2621            tokio::spawn(async move {
2622                let task_id = &task.id;
2623
2624                let mut children: Vec<ExternalId> = Vec::new();
2625                let mut child_statuses: HashMap<
2626                    String,
2627                    TaskStatus,
2628                > = HashMap::new();
2629
2630                match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2631                    Ok(Some(parent_store_id)) => {
2632                        match store_clone.list_children(&repo_owned, parent_store_id).await {
2633                            Ok(store_children) => {
2634                                for child in store_children {
2635                                    let Some(child_external_id) = child.external_id else {
2636                                        continue;
2637                                    };
2638                                    child_statuses.insert(child_external_id.clone(), child.status);
2639                                    children.push(ExternalId(child_external_id));
2640                                }
2641                            }
2642                            Err(e) => {
2643                                tracing::debug!(
2644                                    task_id = task_id.0,
2645                                    ?e,
2646                                    "failed to list store-linked children"
2647                                );
2648                            }
2649                        }
2650                    }
2651                    Ok(None) => {}
2652                    Err(e) => {
2653                        tracing::debug!(
2654                            task_id = task_id.0,
2655                            ?e,
2656                            "failed to resolve blocked task in store for child lookup"
2657                        );
2658                    }
2659                }
2660
2661                if !is_internal_id(&task_id.0) {
2662                    match backend_clone.get_sub_issues(task_id).await {
2663                        Ok(ids) => {
2664                            let mut seen: HashSet<String> =
2665                                children.iter().map(|child| child.0.clone()).collect();
2666                            for id in ids {
2667                                if seen.insert(id.0.clone()) {
2668                                    children.push(id);
2669                                }
2670                            }
2671                        }
2672                        Err(e) => {
2673                            tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2674                        }
2675                    }
2676                }
2677
2678                // No children means nothing to wait on — skip (may be blocked for other reasons)
2679                if children.is_empty() {
2680                    return;
2681                }
2682
2683                // Check if every child is done using the local store (batched) and
2684                // fall back to the backend only for children not present in the store.
2685                let mut all_done = true;
2686                // Build owned strings for the batch lookup (needed for &str slices below)
2687                let child_ext_strs: Vec<String> =
2688                    children.iter().map(|c| c.0.clone()).collect();
2689                let child_exts: Vec<&str> =
2690                    child_ext_strs.iter().map(String::as_str).collect();
2691                // Query store for statuses of any children present locally
2692                match store_clone
2693                    .get_statuses_by_external_ids(&repo_owned, &child_exts)
2694                    .await
2695                { Ok(map) => {
2696                    for (k, v) in map {
2697                        child_statuses.insert(k, v);
2698                    }
2699                } _ => {
2700                    tracing::debug!(
2701                        task_id = task_id.0,
2702                        "failed to batch-query store for child statuses; falling back to per-child lookups"
2703                    );
2704                }}
2705
2706                for child_id in &children {
2707                    // Check store first
2708                    if let Some(status) = child_statuses.get(&child_id.0) {
2709                        if *status != TaskStatus::Done {
2710                            all_done = false;
2711                            break;
2712                        }
2713                        continue;
2714                    }
2715
2716                    // Not found in store — fall back to backend check
2717                    match backend_clone.get_task(child_id).await {
2718                        Ok(child) => {
2719                            if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2720                                all_done = false;
2721                                break;
2722                            }
2723                        }
2724                        Err(e) => {
2725                            tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2726                            all_done = false;
2727                            break;
2728                        }
2729                    }
2730                }
2731
2732                if all_done {
2733                    tracing::info!(
2734                        task_id = task_id.0,
2735                        children = children.len(),
2736                        "all children done, unblocking parent"
2737                    );
2738                    if let Err(e) =
2739                        task_manager_clone.update_task_status(task_id, Status::New).await
2740                    {
2741                        tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2742                    }
2743                }
2744            })
2745        })
2746        .collect();
2747
2748    for handle in handles {
2749        if let Err(e) = handle.await {
2750            tracing::warn!(?e, "phase 4 sub-issue check panicked");
2751        }
2752    }
2753    Ok(())
2754}
2755
2756/// Phase 5 of tick: run cron job matching and fire any due jobs.
2757pub(crate) async fn tick_job_scheduler(
2758    jobs_path: &Path,
2759    backend: &Arc<dyn ExternalBackend>,
2760    store: Option<&Arc<TaskStore>>,
2761    repo: &str,
2762    transport: Option<&Arc<Transport>>,
2763) -> anyhow::Result<()> {
2764    jobs::tick(jobs_path, backend, store, repo, transport).await
2765}
2766
2767/// Core tick for one project, up to the routing phase.
2768///
2769/// Every phase here is scoped to one project, so all projects can run this at the same time:
2770/// 1. `tick_check_session_completions` — poll tmux for finished sessions
2771/// 2. `tick_detect_silent_agents`      — cooldown agents that produced no output
2772/// 3. `tick_recover_stuck_tasks`       — reset in_progress/in_review tasks with no session
2773///
2774/// The store-wide work that used to sit in here runs once per tick in `tick_all`:
2775/// `tick_global_sweeps` and `reconcile_dependencies`.
2776#[expect(
2777    clippy::too_many_arguments,
2778    reason = "each argument is engine state the tick passes down"
2779)]
2780pub(crate) async fn tick_before_routing(
2781    backend: &Arc<dyn ExternalBackend>,
2782    tmux: &Arc<TmuxManager>,
2783    repo: &str,
2784    capture: &Arc<CaptureService>,
2785    config: &EngineConfig,
2786    task_manager: &Arc<TaskManager>,
2787    dispatching: &Arc<DashMap<String, String>>,
2788    store: &Arc<TaskStore>,
2789    session_map: &HashMap<String, bool>,
2790) {
2791    let mut phases: Vec<(&str, u128)> = Vec::new();
2792    let mut at = Instant::now();
2793
2794    if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2795        tracing::warn!(err = %e, "session completion phase failed");
2796    }
2797    phases.push(("sessions", at.elapsed().as_millis()));
2798
2799    // Every node tends its own sessions, only the leader decides. Followers dispatch once
2800    // tasks are assigned to nodes
2801    if !store.is_leader() {
2802        super::log_phase_timings("tick.pre_routing", repo, &phases);
2803        return;
2804    }
2805
2806    at = Instant::now();
2807    if let Err(e) =
2808        tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2809    {
2810        tracing::warn!(err = %e, "silent-agent detection phase failed");
2811    }
2812    phases.push(("silent_agents", at.elapsed().as_millis()));
2813
2814    at = Instant::now();
2815    if let Err(e) = tick_recover_stuck_tasks(
2816        backend,
2817        tmux,
2818        repo,
2819        task_manager,
2820        config,
2821        store,
2822        session_map,
2823        dispatching,
2824    )
2825    .await
2826    {
2827        tracing::warn!(err = %e, "stuck-task recovery phase failed");
2828    }
2829    phases.push(("stuck_tasks", at.elapsed().as_millis()));
2830
2831    super::log_phase_timings("tick.pre_routing", repo, &phases);
2832}
2833
2834/// Core tick for one project, after the routing phase.
2835///
2836/// Takes the router's decision as a `DispatchMode` value instead of the router itself, so
2837/// every project can run this at the same time:
2838/// 1. `tick_dispatch_tasks`  — spawn agents for status:routed tasks
2839/// 2. `tick_unblock_parents` — unblock parents whose sub-issues are all done
2840/// 3. `tick_job_scheduler`   — fire due cron jobs
2841#[expect(
2842    clippy::too_many_arguments,
2843    reason = "each argument is engine state the tick passes down"
2844)]
2845pub(crate) async fn tick_after_routing(
2846    backend: &Arc<dyn ExternalBackend>,
2847    tmux: &Arc<TmuxManager>,
2848    repo: &str,
2849    runner: &Arc<TaskRunner>,
2850    capture: &Arc<CaptureService>,
2851    semaphore: &Arc<Semaphore>,
2852    project_dir: &Path,
2853    task_manager: &Arc<TaskManager>,
2854    weight_tx: &mpsc::Sender<WeightSignal>,
2855    dispatch_mode: DispatchMode,
2856    dispatching: &Arc<DashMap<String, String>>,
2857    store: &Arc<TaskStore>,
2858    transport: Option<&Arc<Transport>>,
2859    session_map: &HashMap<String, bool>,
2860) {
2861    let mut phases: Vec<(&str, u128)> = Vec::new();
2862    let mut at = Instant::now();
2863
2864    if let Err(e) = tick_dispatch_tasks(
2865        backend,
2866        tmux,
2867        repo,
2868        runner,
2869        capture,
2870        semaphore,
2871        task_manager,
2872        weight_tx,
2873        dispatch_mode,
2874        dispatching,
2875        store,
2876        session_map,
2877    )
2878    .await
2879    {
2880        tracing::warn!(err = %e, "dispatch phase failed");
2881    }
2882    phases.push(("dispatch", at.elapsed().as_millis()));
2883
2884    at = Instant::now();
2885    if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2886        tracing::warn!(err = %e, "parent-unblock phase failed");
2887    }
2888    phases.push(("unblock_parents", at.elapsed().as_millis()));
2889
2890    at = Instant::now();
2891    let jobs_path = project_dir.join(".orch.yml");
2892    if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
2893        tracing::error!(?e, "job scheduler tick failed");
2894    }
2895    phases.push(("jobs", at.elapsed().as_millis()));
2896
2897    super::log_phase_timings("tick.post_routing", repo, &phases);
2898}
2899
2900/// Core tick — runs every 10s for every project.
2901///
2902/// Projects no longer wait on each other: the phases around routing run for all of them at
2903/// once, so tick time tracks the slowest project instead of the sum. Routing stays serial
2904/// because it needs the router write lock and one shared `RoutingTickQuota`, which is what
2905/// gives `router.max_tasks_per_tick` its meaning.
2906///
2907/// The tmux session map, the global sweeps and the dependency reconcile are the same for
2908/// every project, so they run once per tick rather than once per project.
2909#[expect(
2910    clippy::too_many_arguments,
2911    reason = "each argument is engine state the tick passes down"
2912)]
2913pub(crate) async fn tick_all(
2914    engines: &[ProjectEngine],
2915    tmux: &Arc<TmuxManager>,
2916    capture: &Arc<CaptureService>,
2917    semaphore: &Arc<Semaphore>,
2918    config: &EngineConfig,
2919    router: &Arc<RwLock<Router>>,
2920    weight_tx: &mpsc::Sender<WeightSignal>,
2921    dispatching: &Arc<DashMap<String, String>>,
2922    transport: Option<&Arc<Transport>>,
2923    active_repos: &HashSet<String>,
2924    auto_merge_in_flight: &Arc<DashSet<String>>,
2925) {
2926    let _tick_span = tracing::info_span!("engine.tick").entered();
2927
2928    let Some(first) = engines.first() else {
2929        return;
2930    };
2931    // All project engines share one SQLite store, so leadership and the global sweeps below
2932    // are the same whichever project they are read from
2933    let store = &first.store;
2934
2935    // Calls made since the previous tick, so hourly quota exhaustion can be traced
2936    for (project, call_type, calls) in http::drain_call_counts().into_iter().take(10) {
2937        tracing::debug!(
2938            project,
2939            call_type,
2940            calls,
2941            "github api calls since last tick"
2942        );
2943    }
2944
2945    // One `tmux list-panes -a` for every project's stuck-task recovery, instead of one each
2946    let session_map = tmux.batch_session_active().await;
2947
2948    let mut passes = Vec::with_capacity(engines.len());
2949    for engine in engines {
2950        passes.push(REPO_CONTEXT.scope(
2951            engine.repo.clone(),
2952            tick_before_routing(
2953                &engine.backend,
2954                tmux,
2955                &engine.repo,
2956                capture,
2957                config,
2958                &engine.task_manager,
2959                dispatching,
2960                &engine.store,
2961                &session_map,
2962            ),
2963        ));
2964    }
2965    future::join_all(passes).await;
2966
2967    if !store.is_leader() {
2968        return;
2969    }
2970
2971    // Store-wide, so once per tick rather than once per project. Both read every repo, and
2972    // the NeedsReview sweep increments a counter it just read, which concurrent per-project
2973    // passes would each consume (#3864)
2974    let at = Instant::now();
2975    {
2976        let router_guard = router.read().await;
2977        tick_global_sweeps(
2978            &first.task_manager,
2979            store,
2980            &router_guard,
2981            dispatching,
2982            auto_merge_in_flight,
2983        )
2984        .await;
2985    }
2986    let sweeps_ms = at.elapsed().as_millis();
2987
2988    let at = Instant::now();
2989    recover_untracked_repo_stuck_tasks(
2990        tmux,
2991        config,
2992        store,
2993        &session_map,
2994        dispatching,
2995        active_repos,
2996    )
2997    .await;
2998    let untracked_ms = at.elapsed().as_millis();
2999
3000    let at = Instant::now();
3001    if let Err(err) = dependencies::reconcile_dependencies(
3002        first.backend.as_ref(),
3003        store,
3004        transport.map(AsRef::as_ref),
3005    )
3006    .await
3007    {
3008        tracing::warn!(err = %err, "dependency reconcile failed");
3009    }
3010    super::log_phase_timings(
3011        "tick.global",
3012        &first.repo,
3013        &[
3014            ("global_sweeps", sweeps_ms),
3015            ("untracked_stuck", untracked_ms),
3016            ("dependencies", at.elapsed().as_millis()),
3017        ],
3018    );
3019
3020    {
3021        let mut router_guard = router.write().await;
3022        let mut routing_quota = RoutingTickQuota::new(router_config::max_tasks_per_routing_tick());
3023        for engine in engines {
3024            let at = Instant::now();
3025            REPO_CONTEXT
3026                .scope(engine.repo.clone(), async {
3027                    if let Err(e) = tick_route_tasks(
3028                        &engine.backend,
3029                        &engine.task_manager,
3030                        &mut router_guard,
3031                        &engine.store,
3032                        &engine.repo,
3033                        &mut routing_quota,
3034                    )
3035                    .await
3036                    {
3037                        tracing::warn!(err = %e, "routing phase failed");
3038                    }
3039                })
3040                .await;
3041            super::log_phase_timings(
3042                "tick.routing",
3043                &engine.repo,
3044                &[("route", at.elapsed().as_millis())],
3045            );
3046        }
3047
3048        // Global routing sweep for tasks from inactive or removed repos.
3049        // tick_route_tasks is scoped to the active repo; tasks from projects no longer
3050        // in config are never returned by list_routable and would stay in 'new' indefinitely.
3051        if let Err(e) =
3052            route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
3053        {
3054            tracing::warn!(err = %e, "global new-task routing sweep failed");
3055        }
3056    }
3057
3058    let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
3059
3060    // Re-read the session map: the completion phase above kills the sessions of finished
3061    // agents, and dispatch must not treat a killed session as one that still blocks a task
3062    let session_map = tmux.batch_session_active().await;
3063
3064    let mut passes = Vec::with_capacity(engines.len());
3065    for engine in engines {
3066        passes.push(REPO_CONTEXT.scope(
3067            engine.repo.clone(),
3068            tick_after_routing(
3069                &engine.backend,
3070                tmux,
3071                &engine.repo,
3072                &engine.runner,
3073                capture,
3074                semaphore,
3075                &engine.project_dir,
3076                &engine.task_manager,
3077                weight_tx,
3078                dispatch_mode,
3079                dispatching,
3080                &engine.store,
3081                transport,
3082                &session_map,
3083            ),
3084        ));
3085    }
3086    future::join_all(passes).await;
3087
3088    // Global dispatch sweep for routed tasks from inactive or removed repos.
3089    // tick_dispatch_tasks is scoped to each active repo; tasks from projects no longer
3090    // in config are never returned by list_external_by_status and would stay in
3091    // 'routed' indefinitely (see #3413). Excludes the full active-repo set, not just
3092    // one repo, so it doesn't treat other active repos as orphaned.
3093    if let Err(e) = dispatch_routed_tasks_global(
3094        tmux,
3095        capture,
3096        semaphore,
3097        weight_tx,
3098        dispatch_mode,
3099        dispatching,
3100        store,
3101        &session_map,
3102        active_repos,
3103    )
3104    .await
3105    {
3106        tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
3107    }
3108}
3109
3110/// Status a finished run moves to. Only a successful run with no PR (or with the review agent
3111/// off) becomes `done`. A failed run stays in `needs_review` with its issue open
3112pub(crate) fn display_status_for(
3113    signal: &WeightSignal,
3114    enable_review: bool,
3115    has_pr: bool,
3116) -> &'static str {
3117    match signal {
3118        WeightSignal::Success { .. } if enable_review => {
3119            if has_pr {
3120                "needs_review"
3121            } else {
3122                "done"
3123            }
3124        }
3125        // Silence detection or another non-rate-limit reset already moved the task to "new".
3126        // Converting it to needs_review would trigger the no-code shortcut and falsely close it
3127        WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
3128        WeightSignal::Blocked => "blocked",
3129        // The review path re-routes a task with no PR and no commits
3130        WeightSignal::Failed { .. } => "needs_review",
3131        // None means the runner guard skipped the task or the status was unrecognised
3132        WeightSignal::None if enable_review => "needs_review",
3133        WeightSignal::Success { .. } | WeightSignal::None => "done",
3134    }
3135}
3136
3137#[cfg(test)]
3138mod tests {
3139    use super::*;
3140    use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3141    use crate::channels::transport::Transport;
3142    use crate::engine::RunTimeouts;
3143    use crate::engine::cooldown;
3144    use crate::engine::router::Router;
3145    use crate::engine::router::RouterConfig;
3146    use crate::engine::tasks::TaskManager;
3147    use crate::store::DepSource;
3148    use crate::store::NewDependency;
3149    use crate::store::NewTask;
3150    use crate::store::StoreRoute;
3151    use crate::store::TaskStatus as DbStatus;
3152    use crate::store::TaskStatus;
3153    use crate::store::TaskStore;
3154    use crate::store::UpsertExternal;
3155    use crate::tmux::{SessionGuard, TmuxManager};
3156    use async_trait::async_trait;
3157    use dashmap::mapref::entry::Entry;
3158    use std::fs;
3159    use std::path::PathBuf;
3160    use std::sync::{Arc, Mutex};
3161    use tokio::sync::RwLock;
3162    use tokio::sync::oneshot;
3163    use tokio::task;
3164
3165    #[test]
3166    fn timed_out_run_without_pr_never_becomes_done() {
3167        let failed = WeightSignal::Failed {
3168            agent: "claude".to_string(),
3169        };
3170        assert_eq!(display_status_for(&failed, true, false), "needs_review");
3171        assert_eq!(display_status_for(&failed, false, false), "needs_review");
3172    }
3173
3174    #[test]
3175    fn done_needs_a_successful_run_with_no_pr() {
3176        let ok = WeightSignal::Success {
3177            agent: "claude".to_string(),
3178        };
3179        assert_eq!(display_status_for(&ok, true, true), "needs_review");
3180        assert_eq!(display_status_for(&ok, true, false), "done");
3181        assert_eq!(
3182            display_status_for(&WeightSignal::Rerouted, true, false),
3183            "new"
3184        );
3185        assert_eq!(
3186            display_status_for(&WeightSignal::None, true, false),
3187            "needs_review"
3188        );
3189    }
3190    #[test]
3191    fn skips_agent_for_human_and_no_agent_labels() {
3192        assert!(skips_agent(&["owner:human".to_string()]));
3193        assert!(skips_agent(&["no-agent".to_string()]));
3194        assert!(!skips_agent(&["status:new".to_string()]));
3195    }
3196
3197    /// A router reporting one available agent with no healthy "review" model, so the
3198    /// global NeedsReview refire sweep in `tick_global_sweeps` treats every review agent
3199    /// as cooled and skips — keeping it a no-op for tests unrelated to it.
3200    fn cooled_review_router_for_test() -> Router {
3201        Router::new_for_test(RouterConfig::default(), vec!["claude".to_string()])
3202    }
3203
3204    // ── minimal mock backend ─────────────────────────────────────────────────
3205
3206    /// Configurable mock backend for tick tests.
3207    struct MockBackend {
3208        /// Tasks returned by `list_by_status(Blocked)`.
3209        blocked_tasks: Vec<ExternalTask>,
3210        /// Sub-issues map: task_id → list of child ExternalIds.
3211        sub_issues: HashMap<String, Vec<ExternalId>>,
3212        /// Tasks returned by `get_task`. Keyed by id.0.
3213        tasks_by_id: HashMap<String, ExternalTask>,
3214        /// Recorded `get_sub_issues` calls.
3215        get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3216        /// Recorded `update_status` calls.
3217        status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3218        /// Recorded `remove_label` calls.
3219        removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3220        /// Recorded `post_comment` calls.
3221        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3222    }
3223
3224    impl MockBackend {
3225        fn new() -> Self {
3226            Self {
3227                blocked_tasks: vec![],
3228                sub_issues: HashMap::new(),
3229                tasks_by_id: HashMap::new(),
3230                get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3231                status_updates: Arc::new(Mutex::new(vec![])),
3232                removed_labels: Arc::new(Mutex::new(vec![])),
3233                posted_comments: Arc::new(Mutex::new(vec![])),
3234            }
3235        }
3236    }
3237
3238    fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3239        ExternalTask {
3240            id: ExternalId(id.to_string()),
3241            title: format!("Task {id}"),
3242            body: String::new(),
3243            state: "open".to_string(),
3244            labels: labels.iter().map(ToString::to_string).collect(),
3245            author: "bot".to_string(),
3246            created_at: "2026-01-01T00:00:00Z".to_string(),
3247            updated_at: "2026-01-01T00:00:00Z".to_string(),
3248            url: format!("https://github.com/test/test/issues/{id}"),
3249        }
3250    }
3251
3252    #[async_trait]
3253    impl ExternalBackend for MockBackend {
3254        fn name(&self) -> &'static str {
3255            "mock"
3256        }
3257        async fn create_task(
3258            &self,
3259            _title: &str,
3260            _body: &str,
3261            _labels: &[String],
3262        ) -> anyhow::Result<ExternalId> {
3263            Ok(ExternalId("new".to_string()))
3264        }
3265        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3266            self.tasks_by_id
3267                .get(&id.0)
3268                .cloned()
3269                .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3270        }
3271        async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3272            if status == Status::Blocked {
3273                Ok(self.blocked_tasks.clone())
3274            } else {
3275                Ok(vec![])
3276            }
3277        }
3278        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3279            Ok(vec![])
3280        }
3281        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3282            self.posted_comments
3283                .lock()
3284                .unwrap()
3285                .push((_id.0.clone(), _body.to_string()));
3286            Ok(())
3287        }
3288        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3289            Ok(())
3290        }
3291        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3292            self.removed_labels
3293                .lock()
3294                .unwrap()
3295                .push((_id.0.clone(), _label.to_string()));
3296            Ok(())
3297        }
3298        async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3299            self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3300            Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3301        }
3302        async fn create_sub_task(
3303            &self,
3304            _parent: &ExternalId,
3305            _title: &str,
3306            _body: &str,
3307            _labels: &[String],
3308        ) -> anyhow::Result<ExternalId> {
3309            Ok(ExternalId("child".to_string()))
3310        }
3311        async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3312            Ok(())
3313        }
3314        async fn has_open_issue_with_title(
3315            &self,
3316            _title: &str,
3317            _label: &str,
3318        ) -> anyhow::Result<bool> {
3319            Ok(false)
3320        }
3321        async fn health_check(&self) -> anyhow::Result<()> {
3322            Ok(())
3323        }
3324        async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3325            Ok(false)
3326        }
3327        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3328            Ok(Some("testbot".to_string()))
3329        }
3330        async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3331            Ok(vec![])
3332        }
3333        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3334            self.status_updates
3335                .lock()
3336                .unwrap()
3337                .push((id.0.clone(), status));
3338            Ok(())
3339        }
3340    }
3341
3342    fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3343        Arc::new(TaskManager::new(backend))
3344    }
3345
3346    // ── tick_unblock_parents ─────────────────────────────────────────────────
3347
3348    #[tokio::test]
3349    async fn unblock_parents_unblocks_when_all_children_done() {
3350        let mut mock = MockBackend::new();
3351
3352        // Blocked parent with two done children
3353        let parent = make_task("10", &["status:blocked"]);
3354        mock.blocked_tasks.push(parent.clone());
3355        mock.sub_issues.insert(
3356            "10".to_string(),
3357            vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3358        );
3359        mock.tasks_by_id
3360            .insert("11".to_string(), make_task("11", &["status:done"]));
3361        mock.tasks_by_id
3362            .insert("12".to_string(), make_task("12", &["status:done"]));
3363
3364        let status_updates = Arc::clone(&mock.status_updates);
3365        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3366        let task_manager = make_task_manager(Arc::clone(&backend));
3367        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3368
3369        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3370            .await
3371            .unwrap();
3372
3373        let updates = status_updates.lock().unwrap();
3374        assert_eq!(updates.len(), 1, "parent should be unblocked");
3375        assert_eq!(updates[0], ("10".to_string(), Status::New));
3376    }
3377
3378    #[tokio::test]
3379    async fn unblock_parents_skips_when_child_not_done() {
3380        let mut mock = MockBackend::new();
3381
3382        let parent = make_task("20", &["status:blocked"]);
3383        mock.blocked_tasks.push(parent.clone());
3384        mock.sub_issues.insert(
3385            "20".to_string(),
3386            vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3387        );
3388        // Child 21 is done, child 22 is still in_progress
3389        mock.tasks_by_id
3390            .insert("21".to_string(), make_task("21", &["status:done"]));
3391        mock.tasks_by_id
3392            .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3393
3394        let status_updates = Arc::clone(&mock.status_updates);
3395        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3396        let task_manager = make_task_manager(Arc::clone(&backend));
3397        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3398
3399        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3400            .await
3401            .unwrap();
3402
3403        let updates = status_updates.lock().unwrap();
3404        assert!(
3405            updates.is_empty(),
3406            "should not unblock when a child is still running"
3407        );
3408    }
3409
3410    #[tokio::test]
3411    async fn unblock_parents_skips_task_with_no_children() {
3412        let mut mock = MockBackend::new();
3413
3414        // Blocked task with no sub-issues (blocked for a different reason)
3415        let parent = make_task("30", &["status:blocked"]);
3416        mock.blocked_tasks.push(parent.clone());
3417        // No entry in sub_issues → get_sub_issues returns []
3418
3419        let status_updates = Arc::clone(&mock.status_updates);
3420        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3421        let task_manager = make_task_manager(Arc::clone(&backend));
3422        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3423
3424        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3425            .await
3426            .unwrap();
3427
3428        let updates = status_updates.lock().unwrap();
3429        assert!(
3430            updates.is_empty(),
3431            "should not unblock tasks with no sub-issues"
3432        );
3433    }
3434
3435    #[tokio::test]
3436    async fn unblock_parents_skips_task_with_block_reason_in_store() {
3437        // A blocked task that has a known block_reason in the store should never
3438        // trigger a get_sub_issues GraphQL call — it is not waiting on children.
3439        let mut mock = MockBackend::new();
3440
3441        // Blocked parent in the backend list
3442        let parent = make_task("60", &["status:blocked"]);
3443        mock.blocked_tasks.push(parent.clone());
3444        // Give it children in the backend so that, if the skip logic is wrong,
3445        // the parent would get unblocked.
3446        mock.sub_issues
3447            .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3448        mock.tasks_by_id
3449            .insert("61".to_string(), make_task("61", &["status:done"]));
3450
3451        let status_updates = Arc::clone(&mock.status_updates);
3452        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3453        let task_manager = make_task_manager(Arc::clone(&backend));
3454
3455        // Insert the blocked task into the store with a block_reason set.
3456        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3457        let task_id = store
3458            .upsert_external(&crate::store::UpsertExternal {
3459                repo: "test/repo",
3460                ext_id: "60",
3461                title: "Task 60",
3462                body: "",
3463                author: "bot",
3464                url: "https://github.com/test/test/issues/60",
3465                labels: &[],
3466                origin: "github",
3467            })
3468            .await
3469            .unwrap();
3470        store
3471            .update_status(task_id, TaskStatus::Blocked)
3472            .await
3473            .unwrap();
3474        store
3475            .set_block_reason(task_id, Some("CI failure limit reached"))
3476            .await
3477            .unwrap();
3478
3479        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3480            .await
3481            .unwrap();
3482
3483        let updates = status_updates.lock().unwrap();
3484        assert!(
3485            updates.is_empty(),
3486            "task with block_reason should be skipped — no API call, no unblock"
3487        );
3488    }
3489
3490    #[tokio::test]
3491    async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3492        let mock = MockBackend::new();
3493        let get_sub_issues_calls = Arc::clone(&mock.get_sub_issues_calls);
3494        let status_updates = Arc::clone(&mock.status_updates);
3495        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3496        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3497        let task_manager = Arc::new(TaskManager::with_store(
3498            Arc::clone(&backend),
3499            Arc::clone(&store),
3500            "test/repo".to_string(),
3501        ));
3502
3503        let parent_id = store
3504            .create_internal("test/repo", "Parent", "", "job", "daily", None)
3505            .await
3506            .unwrap();
3507        store
3508            .update_status(parent_id, TaskStatus::Blocked)
3509            .await
3510            .unwrap();
3511        let child_id = store
3512            .create(&NewTask {
3513                external_id: Some("91".to_string()),
3514                repo: "test/repo".to_string(),
3515                origin: "github".to_string(),
3516                title: "Child".to_string(),
3517                body: String::new(),
3518                source: String::new(),
3519                source_id: String::new(),
3520                author: String::new(),
3521                url: String::new(),
3522                labels: vec![],
3523                parent_id: Some(parent_id),
3524            })
3525            .await
3526            .unwrap();
3527        store
3528            .update_status(child_id, TaskStatus::Done)
3529            .await
3530            .unwrap();
3531
3532        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3533            .await
3534            .unwrap();
3535
3536        let parent = store.get(parent_id).await.unwrap();
3537        assert_eq!(parent.status, TaskStatus::New);
3538        assert!(
3539            status_updates.lock().unwrap().is_empty(),
3540            "internal parent should be updated in store without backend mirroring"
3541        );
3542        assert!(
3543            get_sub_issues_calls.lock().unwrap().is_empty(),
3544            "internal parent should not query backend sub-issues"
3545        );
3546    }
3547
3548    // ── tick_recover_stuck_tasks (InReview) ──────────────────────────────────
3549
3550    /// Set `updated_at` to a far-past date for a task in an in-memory store.
3551    #[cfg(test)]
3552    async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3553        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3554            .bind(task_id)
3555            .execute(store.pool())
3556            .await
3557            .unwrap();
3558    }
3559
3560    /// Age the in_review status-entry the same way the stale sweep tests do
3561    async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3562        sqlx::query(
3563            "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3564             WHERE task_id = ? AND to_status = 'in_review'",
3565        )
3566        .bind(task_id)
3567        .execute(store.pool())
3568        .await
3569        .unwrap();
3570    }
3571
3572    #[tokio::test]
3573    async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3574        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3575        let mock = MockBackend::new();
3576        let status_updates = Arc::clone(&mock.status_updates);
3577        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3578        let task_manager = Arc::new(TaskManager::with_store(
3579            Arc::clone(&backend),
3580            Arc::clone(&store),
3581            "owner/repo".to_string(),
3582        ));
3583        let tmux = Arc::new(TmuxManager::new());
3584        let config = EngineConfig {
3585            no_session_stuck_timeout: 600,
3586            stuck_timeout: 1800,
3587            ..EngineConfig::default()
3588        };
3589
3590        // Insert an external task and age both the status entry and updated_at
3591        let id = store
3592            .upsert_external(&crate::store::UpsertExternal {
3593                repo: "owner/repo",
3594                ext_id: "99",
3595                title: "Stuck InReview",
3596                body: "",
3597                author: "",
3598                url: "",
3599                labels: &[],
3600                origin: "github",
3601            })
3602            .await
3603            .unwrap();
3604        store.update_status(id, TaskStatus::InReview).await.unwrap();
3605        set_in_review_entered_at_past(&store, id).await;
3606        set_task_updated_at_past(&store, id).await;
3607
3608        tick_recover_stuck_tasks(
3609            &backend,
3610            &tmux,
3611            "owner/repo",
3612            &task_manager,
3613            &config,
3614            &store,
3615            &HashMap::new(),
3616            &Arc::new(DashMap::new()),
3617        )
3618        .await
3619        .unwrap();
3620
3621        let updates = status_updates.lock().unwrap();
3622        assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3623        assert_eq!(updates[0].0, "99");
3624        assert_eq!(updates[0].1, Status::NeedsReview);
3625    }
3626
3627    #[tokio::test]
3628    async fn recover_stuck_tasks_skips_recent_in_review() {
3629        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3630        let mock = MockBackend::new();
3631        let status_updates = Arc::clone(&mock.status_updates);
3632        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3633        let task_manager = Arc::new(TaskManager::with_store(
3634            Arc::clone(&backend),
3635            Arc::clone(&store),
3636            "owner/repo".to_string(),
3637        ));
3638        let tmux = Arc::new(TmuxManager::new());
3639        let config = EngineConfig {
3640            no_session_stuck_timeout: 600, // 10 minutes — recent task won't trigger
3641            stuck_timeout: 1800,
3642            ..EngineConfig::default()
3643        };
3644
3645        // Age updated_at, leave the in_review status entry at now
3646        let id = store
3647            .upsert_external(&crate::store::UpsertExternal {
3648                repo: "owner/repo",
3649                ext_id: "100",
3650                title: "Recent InReview",
3651                body: "",
3652                author: "",
3653                url: "",
3654                labels: &[],
3655                origin: "github",
3656            })
3657            .await
3658            .unwrap();
3659        store.update_status(id, TaskStatus::InReview).await.unwrap();
3660        set_task_updated_at_past(&store, id).await;
3661
3662        tick_recover_stuck_tasks(
3663            &backend,
3664            &tmux,
3665            "owner/repo",
3666            &task_manager,
3667            &config,
3668            &store,
3669            &HashMap::new(),
3670            &Arc::new(DashMap::new()),
3671        )
3672        .await
3673        .unwrap();
3674
3675        let updates = status_updates.lock().unwrap();
3676        assert!(
3677            updates.is_empty(),
3678            "recent InReview task should not be recovered"
3679        );
3680    }
3681
3682    #[tokio::test]
3683    async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3684        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3685        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3686        let task_manager = Arc::new(TaskManager::with_store(
3687            Arc::clone(&backend),
3688            Arc::clone(&store),
3689            "owner/repo".to_string(),
3690        ));
3691        let tmux = Arc::new(TmuxManager::new());
3692        let config = EngineConfig {
3693            no_session_stuck_timeout: 600,
3694            stuck_timeout: 1800,
3695            ..EngineConfig::default()
3696        };
3697
3698        // Create an internal task in InReview and age both clocks
3699        let id = store
3700            .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3701            .await
3702            .unwrap();
3703        store.update_status(id, TaskStatus::InReview).await.unwrap();
3704        set_in_review_entered_at_past(&store, id).await;
3705        set_task_updated_at_past(&store, id).await;
3706
3707        tick_recover_stuck_tasks(
3708            &backend,
3709            &tmux,
3710            "owner/repo",
3711            &task_manager,
3712            &config,
3713            &store,
3714            &HashMap::new(),
3715            &Arc::new(DashMap::new()),
3716        )
3717        .await
3718        .unwrap();
3719
3720        let task = store.get(id).await.unwrap();
3721        assert_eq!(
3722            task.status,
3723            TaskStatus::NeedsReview,
3724            "stuck internal InReview task should be reset to NeedsReview"
3725        );
3726    }
3727
3728    #[tokio::test]
3729    async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3730        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3731        let mock = MockBackend::new();
3732        let status_updates = Arc::clone(&mock.status_updates);
3733        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3734        let task_manager = Arc::new(TaskManager::with_store(
3735            Arc::clone(&backend),
3736            Arc::clone(&store),
3737            "owner/repo".to_string(),
3738        ));
3739        let tmux = Arc::new(TmuxManager::new());
3740        let config = EngineConfig {
3741            no_session_stuck_timeout: 600,
3742            stuck_timeout: 1800,
3743            ..EngineConfig::default()
3744        };
3745
3746        let id = store
3747            .upsert_external(&crate::store::UpsertExternal {
3748                repo: "owner/repo",
3749                ext_id: "102",
3750                title: "Fresh updated_at InReview",
3751                body: "",
3752                author: "",
3753                url: "",
3754                labels: &[],
3755                origin: "github",
3756            })
3757            .await
3758            .unwrap();
3759        store.update_status(id, TaskStatus::InReview).await.unwrap();
3760        set_in_review_entered_at_past(&store, id).await;
3761        store
3762            .set_fields(id, &[("pr_number", serde_json::json!(42))])
3763            .await
3764            .unwrap();
3765
3766        tick_recover_stuck_tasks(
3767            &backend,
3768            &tmux,
3769            "owner/repo",
3770            &task_manager,
3771            &config,
3772            &store,
3773            &HashMap::new(),
3774            &Arc::new(DashMap::new()),
3775        )
3776        .await
3777        .unwrap();
3778
3779        let updates = status_updates.lock().unwrap();
3780        assert_eq!(
3781            updates.len(),
3782            1,
3783            "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3784        );
3785        assert_eq!(updates[0].0, "102");
3786        assert_eq!(updates[0].1, Status::NeedsReview);
3787    }
3788
3789    /// An in_review task whose review session is gone goes back to needs_review, unless its
3790    /// review already recorded a decision and the task is only waiting on the merge
3791    async fn orphaned_review_status_after_recovery(with_decision: bool) -> TaskStatus {
3792        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3793        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3794        let task_manager = Arc::new(TaskManager::with_store(
3795            Arc::clone(&backend),
3796            Arc::clone(&store),
3797            "owner/repo".to_string(),
3798        ));
3799        let tmux = Arc::new(TmuxManager::new());
3800
3801        let id = store
3802            .upsert_external(&crate::store::UpsertExternal {
3803                repo: "owner/repo",
3804                ext_id: "55",
3805                title: "Orphaned review",
3806                body: "",
3807                author: "",
3808                url: "",
3809                labels: &[],
3810                origin: "github",
3811            })
3812            .await
3813            .unwrap();
3814        store.update_status(id, TaskStatus::InReview).await.unwrap();
3815        // Entered in_review long ago. updated_at stays fresh, as review_poll rewrites it
3816        set_in_review_entered_at_past(&store, id).await;
3817        if with_decision {
3818            store::store_log_activity(
3819                &Some(Arc::clone(&store)),
3820                "owner/repo",
3821                "55",
3822                "review_decision",
3823                None,
3824                None,
3825                None::<&str>,
3826                None::<&str>,
3827                Some(&serde_json::json!({"decision": "approve"})),
3828            )
3829            .await;
3830        }
3831
3832        tick_recover_stuck_tasks(
3833            &backend,
3834            &tmux,
3835            "owner/repo",
3836            &task_manager,
3837            &EngineConfig::default(),
3838            &store,
3839            &HashMap::new(),
3840            &Arc::new(DashMap::new()),
3841        )
3842        .await
3843        .unwrap();
3844
3845        store.get(id).await.unwrap().status
3846    }
3847
3848    #[tokio::test]
3849    async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3850        assert_eq!(
3851            orphaned_review_status_after_recovery(false).await,
3852            TaskStatus::NeedsReview
3853        );
3854    }
3855
3856    #[tokio::test]
3857    async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3858        assert_eq!(
3859            orphaned_review_status_after_recovery(true).await,
3860            TaskStatus::InReview
3861        );
3862    }
3863
3864    #[tokio::test]
3865    async fn recover_stuck_tasks_resets_review_agent_failures() {
3866        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3867        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3868        let task_manager = Arc::new(TaskManager::with_store(
3869            Arc::clone(&backend),
3870            Arc::clone(&store),
3871            "owner/repo".to_string(),
3872        ));
3873        let tmux = Arc::new(TmuxManager::new());
3874
3875        let id = store
3876            .upsert_external(&crate::store::UpsertExternal {
3877                repo: "owner/repo",
3878                ext_id: "104",
3879                title: "Orphaned review with failures",
3880                body: "",
3881                author: "",
3882                url: "",
3883                labels: &[],
3884                origin: "github",
3885            })
3886            .await
3887            .unwrap();
3888        store.update_status(id, TaskStatus::InReview).await.unwrap();
3889        store
3890            .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3891            .await
3892            .unwrap();
3893        set_in_review_entered_at_past(&store, id).await;
3894
3895        tick_recover_stuck_tasks(
3896            &backend,
3897            &tmux,
3898            "owner/repo",
3899            &task_manager,
3900            &EngineConfig::default(),
3901            &store,
3902            &HashMap::new(),
3903            &Arc::new(DashMap::new()),
3904        )
3905        .await
3906        .unwrap();
3907
3908        let task = store.get(id).await.unwrap();
3909        assert_eq!(task.status, TaskStatus::NeedsReview);
3910        assert_eq!(
3911            task.review_agent_failures, 0,
3912            "a dead review session is an infrastructure event, not an agent failure"
3913        );
3914    }
3915
3916    #[tokio::test]
3917    async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3918        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3919        let mock = MockBackend::new();
3920        let status_updates = Arc::clone(&mock.status_updates);
3921        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3922        let task_manager = Arc::new(TaskManager::with_store(
3923            Arc::clone(&backend),
3924            Arc::clone(&store),
3925            "owner/repo".to_string(),
3926        ));
3927        let tmux = Arc::new(TmuxManager::new());
3928        let config = EngineConfig {
3929            no_session_stuck_timeout: 600,
3930            stuck_timeout: 1800,
3931            ..EngineConfig::default()
3932        };
3933
3934        let id = store
3935            .upsert_external(&crate::store::UpsertExternal {
3936                repo: "owner/repo",
3937                ext_id: "103",
3938                title: "Hung review session",
3939                body: "",
3940                author: "",
3941                url: "",
3942                labels: &[],
3943                origin: "github",
3944            })
3945            .await
3946            .unwrap();
3947        store.update_status(id, TaskStatus::InReview).await.unwrap();
3948        set_in_review_entered_at_past(&store, id).await;
3949        store
3950            .set_fields(id, &[("pr_number", serde_json::json!(43))])
3951            .await
3952            .unwrap();
3953
3954        let session_name = tmux.session_name("owner/repo", "103-review");
3955        let mut session_map = HashMap::new();
3956        session_map.insert(session_name, true);
3957
3958        tick_recover_stuck_tasks(
3959            &backend,
3960            &tmux,
3961            "owner/repo",
3962            &task_manager,
3963            &config,
3964            &store,
3965            &session_map,
3966            &Arc::new(DashMap::new()),
3967        )
3968        .await
3969        .unwrap();
3970
3971        let updates = status_updates.lock().unwrap();
3972        assert_eq!(
3973            updates.len(),
3974            1,
3975            "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3976        );
3977        assert_eq!(updates[0].0, "103");
3978        assert_eq!(updates[0].1, Status::NeedsReview);
3979    }
3980
3981    #[tokio::test]
3982    async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3983        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3984        let tmux = Arc::new(TmuxManager::new());
3985        let config = EngineConfig {
3986            no_session_stuck_timeout: 600,
3987            stuck_timeout: 1800,
3988            ..EngineConfig::default()
3989        };
3990
3991        // Cross-repo task should still be recovered even when current tick repo is owner/repo.
3992        let id = store
3993            .upsert_external(&crate::store::UpsertExternal {
3994                repo: "owner/other",
3995                ext_id: "777",
3996                title: "Cross-repo stuck task",
3997                body: "",
3998                author: "",
3999                url: "",
4000                labels: &[],
4001                origin: "github",
4002            })
4003            .await
4004            .unwrap();
4005        store
4006            .update_status(id, TaskStatus::InProgress)
4007            .await
4008            .unwrap();
4009        set_task_updated_at_past(&store, id).await;
4010
4011        let active = HashSet::from(["owner/repo".to_string()]);
4012        recover_untracked_repo_stuck_tasks(
4013            &tmux,
4014            &config,
4015            &store,
4016            &HashMap::new(),
4017            &Arc::new(DashMap::new()),
4018            &active,
4019        )
4020        .await;
4021
4022        let task = store.get(id).await.unwrap();
4023        assert_eq!(
4024            task.status,
4025            TaskStatus::New,
4026            "cross-repo stale in_progress task should be reset to New"
4027        );
4028        assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
4029        assert!(task.agent.is_none(), "agent should be cleared");
4030        assert!(task.model.is_none(), "model should be cleared");
4031    }
4032
4033    #[tokio::test]
4034    async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
4035        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4036        let tmux = Arc::new(TmuxManager::new());
4037        let config = EngineConfig {
4038            no_session_stuck_timeout: 600,
4039            stuck_timeout: 1800,
4040            ..EngineConfig::default()
4041        };
4042        let id = store
4043            .upsert_external(&crate::store::UpsertExternal {
4044                repo: "owner/other",
4045                ext_id: "778",
4046                title: "Stuck task in an active repo",
4047                body: "",
4048                author: "",
4049                url: "",
4050                labels: &[],
4051                origin: "github",
4052            })
4053            .await
4054            .unwrap();
4055        store
4056            .update_status(id, TaskStatus::InProgress)
4057            .await
4058            .unwrap();
4059        set_task_updated_at_past(&store, id).await;
4060
4061        // Per-project passes run concurrently, so only the task's own pass may reclaim it
4062        let active = HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
4063        recover_untracked_repo_stuck_tasks(
4064            &tmux,
4065            &config,
4066            &store,
4067            &HashMap::new(),
4068            &Arc::new(DashMap::new()),
4069            &active,
4070        )
4071        .await;
4072
4073        assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
4074    }
4075
4076    #[tokio::test]
4077    async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
4078        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4079        let mock = MockBackend::new();
4080        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4081        let task_manager = Arc::new(TaskManager::with_store(
4082            Arc::clone(&backend),
4083            Arc::clone(&store),
4084            "owner/repo".to_string(),
4085        ));
4086        let tmux = Arc::new(TmuxManager::new());
4087        let config = EngineConfig {
4088            no_session_stuck_timeout: 600,
4089            stuck_timeout: 1800,
4090            ..EngineConfig::default()
4091        };
4092
4093        let id = store
4094            .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
4095            .await
4096            .unwrap();
4097        store
4098            .set_fields(
4099                id,
4100                &[
4101                    ("agent", serde_json::json!("claude")),
4102                    ("model", serde_json::json!("sonnet")),
4103                    ("route_attempts", serde_json::json!(1)),
4104                ],
4105            )
4106            .await
4107            .unwrap();
4108        store
4109            .update_status(id, TaskStatus::InProgress)
4110            .await
4111            .unwrap();
4112        set_task_updated_at_past(&store, id).await;
4113
4114        // Start an agent run that will be orphaned by recovery
4115        store
4116            .start_run(&crate::store::StartRun {
4117                task_id: id,
4118                attempt: 1,
4119                run_type: "agent",
4120                agent: "claude",
4121                model: "sonnet",
4122                command: "claude --model sonnet",
4123                prompt: "fix it",
4124            })
4125            .await
4126            .unwrap();
4127
4128        tick_recover_stuck_tasks(
4129            &backend,
4130            &tmux,
4131            "owner/repo",
4132            &task_manager,
4133            &config,
4134            &store,
4135            &HashMap::new(),
4136            &Arc::new(DashMap::new()),
4137        )
4138        .await
4139        .unwrap();
4140
4141        let task = store.get(id).await.unwrap();
4142        assert_eq!(task.status, TaskStatus::New);
4143
4144        let runs = store.get_runs(id).await.unwrap();
4145        assert_eq!(runs.len(), 1);
4146        assert_eq!(runs[0].outcome, "aborted");
4147        assert!(runs[0].completed_at.is_some());
4148        assert_eq!(
4149            runs[0].error,
4150            "stuck-task recovery: internal no session found"
4151        );
4152    }
4153
4154    /// Regression test for #3518: a completed agent run must not be discarded by the
4155    /// stuck-task reclaim just because its tmux session has already exited.
4156    ///
4157    /// In production, sessions are created without `remain-on-exit`, so once the agent
4158    /// process exits tmux removes the session immediately — there is no observable
4159    /// "dead pane" state for `tick_check_session_completions` to catch, and the runner's
4160    /// own `store_touch_updated_at` only fires after its own poll notices the exit (up to
4161    /// its 5s poll interval) plus post-processing. For a task whose total runtime already
4162    /// exceeds `no_session_stuck_timeout`, the very next tick that observes the (now
4163    /// absent) session would otherwise reclaim it instantly — discarding a successful run.
4164    ///
4165    /// The fix: `tick_recover_stuck_tasks` skips the no-session reclaim while the task's
4166    /// dispatch guard (the in-memory `dispatching` map keyed by "{repo}/{task_id}",
4167    /// populated for the entire duration of `runner.run_with_context`) is still held —
4168    /// i.e. while this process is still actively running the task, regardless of tmux
4169    /// session state.
4170    #[tokio::test]
4171    async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4172        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4173        let mock = MockBackend::new();
4174        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4175        let task_manager = Arc::new(TaskManager::with_store(
4176            Arc::clone(&backend),
4177            Arc::clone(&store),
4178            "owner/repo".to_string(),
4179        ));
4180        let tmux = Arc::new(TmuxManager::new());
4181        let config = EngineConfig {
4182            no_session_stuck_timeout: 600,
4183            stuck_timeout: 1800,
4184            ..EngineConfig::default()
4185        };
4186
4187        let id = store
4188            .create_internal(
4189                "owner/repo",
4190                "Long-running daily job",
4191                "",
4192                "cron",
4193                "1",
4194                None,
4195            )
4196            .await
4197            .unwrap();
4198        store
4199            .set_fields(
4200                id,
4201                &[
4202                    ("agent", serde_json::json!("claude")),
4203                    ("model", serde_json::json!("sonnet")),
4204                    ("route_attempts", serde_json::json!(1)),
4205                ],
4206            )
4207            .await
4208            .unwrap();
4209        store
4210            .update_status(id, TaskStatus::InProgress)
4211            .await
4212            .unwrap();
4213        // Simulate a task that has already run well past no_session_stuck_timeout,
4214        // as a legitimately long agent run would.
4215        set_task_updated_at_past(&store, id).await;
4216
4217        // Simulate the runner still actively processing this task: its dispatch guard
4218        // is held even though the tmux session (empty session_map below) is already gone.
4219        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4220        dispatching.insert(
4221            format!("owner/repo/internal:{id}"),
4222            format!("internal:{id}"),
4223        );
4224
4225        tick_recover_stuck_tasks(
4226            &backend,
4227            &tmux,
4228            "owner/repo",
4229            &task_manager,
4230            &config,
4231            &store,
4232            &HashMap::new(),
4233            &dispatching,
4234        )
4235        .await
4236        .unwrap();
4237
4238        let task = store.get(id).await.unwrap();
4239        assert_eq!(
4240            task.status,
4241            TaskStatus::InProgress,
4242            "task should not be reclaimed while its dispatch guard is still held"
4243        );
4244
4245        // No run should have been finalized/aborted either.
4246        let runs = store.get_runs(id).await.unwrap();
4247        assert!(runs.is_empty());
4248
4249        // Once the guard is released (runner finished), the very same conditions should
4250        // reclaim the task as before — the fix must not disable the safety net entirely.
4251        dispatching.clear();
4252        tick_recover_stuck_tasks(
4253            &backend,
4254            &tmux,
4255            "owner/repo",
4256            &task_manager,
4257            &config,
4258            &store,
4259            &HashMap::new(),
4260            &dispatching,
4261        )
4262        .await
4263        .unwrap();
4264        let task = store.get(id).await.unwrap();
4265        assert_eq!(
4266            task.status,
4267            TaskStatus::New,
4268            "task should still be reclaimed once the dispatch guard is released"
4269        );
4270    }
4271
4272    /// Concurrency regression test for issue #3526: the reclaim race recurred twice
4273    /// (internal:156563 in #3523, internal:156673 in #3526) *after* the #3519/#3525
4274    /// fix landed, in production under the real multi-threaded tokio runtime. The
4275    /// existing `recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held` test
4276    /// above only exercises sequential `.await` calls on a single-threaded test
4277    /// runtime (`#[tokio::test]` defaults to `flavor = "current_thread"`), which can
4278    /// never observe genuine cross-thread races in the shared `dispatching` DashMap.
4279    ///
4280    /// This test claims the guard exactly the way `dispatch_tasks_for_repo` does
4281    /// (atomic `DashMap::entry` claim, `DispatchGuard` RAII release), hands it to a
4282    /// spawned task running on a *different* worker thread that holds it until
4283    /// explicitly told to release, and hammers the real `tick_recover_stuck_tasks`
4284    /// from the main test task hundreds of times while the guard is deterministically
4285    /// still held. If there were a visibility gap between `DashMap::entry().insert()`
4286    /// on one thread and `DashMap::contains_key()` on another, this test would catch it.
4287    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4288    async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4289        const HAMMER_ITERATIONS: u32 = 300;
4290
4291        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4292        let mock = MockBackend::new();
4293        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4294        let task_manager = Arc::new(TaskManager::with_store(
4295            Arc::clone(&backend),
4296            Arc::clone(&store),
4297            "owner/repo".to_string(),
4298        ));
4299        let tmux = Arc::new(TmuxManager::new());
4300        let config = EngineConfig {
4301            no_session_stuck_timeout: 600,
4302            stuck_timeout: 1800,
4303            ..EngineConfig::default()
4304        };
4305
4306        let id = store
4307            .create_internal(
4308                "owner/repo",
4309                "Long-running daily job",
4310                "",
4311                "cron",
4312                "1",
4313                None,
4314            )
4315            .await
4316            .unwrap();
4317        store
4318            .set_fields(
4319                id,
4320                &[
4321                    ("agent", serde_json::json!("claude")),
4322                    ("model", serde_json::json!("sonnet")),
4323                    ("route_attempts", serde_json::json!(1)),
4324                ],
4325            )
4326            .await
4327            .unwrap();
4328        store
4329            .update_status(id, TaskStatus::InProgress)
4330            .await
4331            .unwrap();
4332        set_task_updated_at_past(&store, id).await;
4333
4334        let dispatch_key = format!("owner/repo/internal:{id}");
4335        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4336
4337        // Claim the guard synchronously, on this task, exactly like
4338        // dispatch_tasks_for_repo does: the atomic entry-claim always happens before
4339        // tokio::spawn hands the guard to the background task. (Claiming inside the
4340        // spawned task itself would race the hammer loop below against tokio's own
4341        // scheduling of that task onto a worker thread — a test artifact, not the
4342        // real production ordering, which guarantees claim-before-spawn.)
4343        let claim_task_id = format!("internal:{id}");
4344        {
4345            match dispatching.entry(dispatch_key.clone()) {
4346                Entry::Vacant(slot) => {
4347                    slot.insert(claim_task_id);
4348                }
4349                Entry::Occupied(_) => panic!("dispatch key already claimed"),
4350            }
4351        }
4352        let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4353
4354        // Hold the guard on a spawned task — a different worker thread under the
4355        // multi-thread runtime — mirroring the real runner holding it across genuine
4356        // async work (worktree setup, agent session, response handling) inside
4357        // `tokio::spawn(... run_with_context ...)`. It only drops the guard once
4358        // explicitly told to via `release_rx`, so its lifetime is fully controlled
4359        // by this test rather than raced against a timer — the release-signal
4360        // ordering was itself a source of false positives in an earlier version of
4361        // this test (a `sleep`-then-drop holder can legitimately release the guard
4362        // in the gap between the hammer loop's last check and its next iteration,
4363        // which is a correct outcome, not a bug, but made the assertion flaky).
4364        let (release_tx, release_rx) = oneshot::channel::<()>();
4365        let holder = tokio::spawn(async move {
4366            let _ = release_rx.await;
4367            drop(guard);
4368        });
4369
4370        // Hammer the real reclaim path concurrently from the main task while the
4371        // guard is deterministically still held (the holder task is parked on
4372        // `release_rx` and cannot drop the guard until we signal it below). Each
4373        // iteration's `tick_recover_stuck_tasks` call may itself run on any worker
4374        // thread the tokio scheduler picks, independent of the thread that claimed
4375        // the guard — this is what actually exercises cross-thread DashMap
4376        // visibility, not wall-clock racing against a timer.
4377        for i in 0..HAMMER_ITERATIONS {
4378            tick_recover_stuck_tasks(
4379                &backend,
4380                &tmux,
4381                "owner/repo",
4382                &task_manager,
4383                &config,
4384                &store,
4385                &HashMap::new(),
4386                &dispatching,
4387            )
4388            .await
4389            .unwrap();
4390
4391            let task = store.get(id).await.unwrap();
4392            assert_eq!(
4393                task.status,
4394                TaskStatus::InProgress,
4395                "task must never be reclaimed while the dispatch guard is deterministically \
4396                 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4397                 iteration={i}, contains_key_now={}, map_len_now={})",
4398                dispatching.contains_key(&dispatch_key),
4399                dispatching.len(),
4400            );
4401            task::yield_now().await;
4402        }
4403
4404        // Now release the guard and confirm the holder task actually completed.
4405        let _ = release_tx.send(());
4406        holder.await.unwrap();
4407
4408        // Guard released — the safety net must still reclaim as before.
4409        tick_recover_stuck_tasks(
4410            &backend,
4411            &tmux,
4412            "owner/repo",
4413            &task_manager,
4414            &config,
4415            &store,
4416            &HashMap::new(),
4417            &dispatching,
4418        )
4419        .await
4420        .unwrap();
4421        let task = store.get(id).await.unwrap();
4422        assert_eq!(
4423            task.status,
4424            TaskStatus::New,
4425            "task should be reclaimed once the dispatch guard is released"
4426        );
4427    }
4428
4429    #[tokio::test]
4430    async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4431        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4432        let mock = MockBackend::new();
4433        let status_updates = Arc::clone(&mock.status_updates);
4434        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4435        let task_manager = Arc::new(TaskManager::with_store(
4436            Arc::clone(&backend),
4437            Arc::clone(&store),
4438            "owner/repo".to_string(),
4439        ));
4440        let tmux = Arc::new(TmuxManager::new());
4441        let config = EngineConfig {
4442            no_session_stuck_timeout: 600,
4443            stuck_timeout: 1800,
4444            ..EngineConfig::default()
4445        };
4446
4447        let id = store
4448            .upsert_external(&crate::store::UpsertExternal {
4449                repo: "owner/repo",
4450                ext_id: "101",
4451                title: "Waiting For Human Review",
4452                body: "",
4453                author: "",
4454                url: "",
4455                labels: &[],
4456                origin: "github",
4457            })
4458            .await
4459            .unwrap();
4460        store.update_status(id, TaskStatus::InReview).await.unwrap();
4461        set_task_updated_at_past(&store, id).await;
4462
4463        tick_recover_stuck_tasks(
4464            &backend,
4465            &tmux,
4466            "owner/repo",
4467            &task_manager,
4468            &config,
4469            &store,
4470            &HashMap::new(),
4471            &Arc::new(DashMap::new()),
4472        )
4473        .await
4474        .unwrap();
4475
4476        let updates = status_updates.lock().unwrap();
4477        assert!(
4478            updates.is_empty(),
4479            "InReview task waiting for human review should not be reset"
4480        );
4481    }
4482
4483    #[tokio::test]
4484    async fn unblock_parents_no_blocked_tasks() {
4485        let mock = MockBackend::new(); // no blocked tasks
4486        let status_updates = Arc::clone(&mock.status_updates);
4487        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4488        let task_manager = make_task_manager(Arc::clone(&backend));
4489        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4490
4491        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4492            .await
4493            .unwrap();
4494
4495        assert!(status_updates.lock().unwrap().is_empty());
4496    }
4497
4498    #[tokio::test]
4499    async fn unblock_parents_handles_multiple_blocked_tasks() {
4500        let mut mock = MockBackend::new();
4501
4502        // Task 40: all children done → should unblock
4503        let t40 = make_task("40", &["status:blocked"]);
4504        mock.blocked_tasks.push(t40);
4505        mock.sub_issues
4506            .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4507        mock.tasks_by_id
4508            .insert("41".to_string(), make_task("41", &["status:done"]));
4509
4510        // Task 50: child not done → should stay blocked
4511        let t50 = make_task("50", &["status:blocked"]);
4512        mock.blocked_tasks.push(t50);
4513        mock.sub_issues
4514            .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4515        mock.tasks_by_id
4516            .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4517
4518        let status_updates = Arc::clone(&mock.status_updates);
4519        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4520        let task_manager = make_task_manager(Arc::clone(&backend));
4521        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4522
4523        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4524            .await
4525            .unwrap();
4526
4527        let updates = status_updates.lock().unwrap();
4528        assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4529        assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4530        assert_eq!(updates[0].1, Status::New);
4531    }
4532
4533    /// `tick_all` reads leadership and the global sweeps from the first project's store, so
4534    /// an empty project list must return before any of that, and touch nothing.
4535    #[tokio::test]
4536    async fn tick_all_is_a_no_op_without_projects() {
4537        let transport = Arc::new(Transport::new());
4538        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4539        let tmux = Arc::new(TmuxManager::new());
4540        let semaphore = Arc::new(Semaphore::new(1));
4541        let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4542        let (weight_tx, mut weight_rx) = mpsc::channel(8);
4543        let dispatching = Arc::new(DashMap::new());
4544        let auto_merge_in_flight = Arc::new(DashSet::new());
4545
4546        tick_all(
4547            &[],
4548            &tmux,
4549            &capture,
4550            &semaphore,
4551            &EngineConfig::default(),
4552            &router,
4553            &weight_tx,
4554            &dispatching,
4555            Some(&transport),
4556            &HashSet::new(),
4557            &auto_merge_in_flight,
4558        )
4559        .await;
4560
4561        assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4562        assert!(
4563            weight_rx.try_recv().is_err(),
4564            "no project, so no weight signal"
4565        );
4566    }
4567
4568    /// The store-wide sweeps must run once per tick, not once per project. The NeedsReview
4569    /// refire sweep reads `needs_review_refires` and then increments it, so when it ran
4570    /// inside the per-project join every project saw the same count and spent one of the
4571    /// five refire attempts — four projects burned four attempts in a single tick (#3864).
4572    #[serial_test::serial(cooldown_state)]
4573    #[tokio::test]
4574    async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4575        cooldown::reset_global_state().await;
4576
4577        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4578        let id = store
4579            .upsert_external(&UpsertExternal {
4580                repo: "owner/one",
4581                ext_id: "7",
4582                title: "Stale needs_review task",
4583                body: "",
4584                author: "user",
4585                url: "https://github.com/owner/one/issues/7",
4586                labels: &[],
4587                origin: "github",
4588            })
4589            .await
4590            .unwrap();
4591        store
4592            .update_status(id, TaskStatus::NeedsReview)
4593            .await
4594            .unwrap();
4595        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4596            .bind(id)
4597            .execute(store.pool())
4598            .await
4599            .unwrap();
4600
4601        let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4602            .iter()
4603            .map(|repo| {
4604                let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4605                ProjectEngine {
4606                    repo: repo.to_string(),
4607                    project_dir: PathBuf::from("/nonexistent"),
4608                    backend: Arc::clone(&backend),
4609                    task_manager: Arc::new(TaskManager::with_store(
4610                        backend,
4611                        Arc::clone(&store),
4612                        repo.to_string(),
4613                    )),
4614                    runner: Arc::new(TaskRunner::new(repo.to_string())),
4615                    store: Arc::clone(&store),
4616                }
4617            })
4618            .collect();
4619
4620        // A healthy review agent, so the sweep does not skip the pass
4621        let mut config = RouterConfig::default();
4622        config
4623            .model_map
4624            .entry("review".to_string())
4625            .or_default()
4626            .insert("claude".to_string(), vec!["sonnet".to_string()]);
4627        let router = Arc::new(RwLock::new(Router::new_for_test(
4628            config,
4629            vec!["claude".to_string()],
4630        )));
4631
4632        let transport = Arc::new(Transport::new());
4633        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4634        let (weight_tx, _weight_rx) = mpsc::channel(8);
4635
4636        tick_all(
4637            &engines,
4638            &Arc::new(TmuxManager::new()),
4639            &capture,
4640            &Arc::new(Semaphore::new(1)),
4641            &EngineConfig::default(),
4642            &router,
4643            &weight_tx,
4644            &Arc::new(DashMap::new()),
4645            Some(&transport),
4646            &engines.iter().map(|e| e.repo.clone()).collect(),
4647            &Arc::new(DashSet::new()),
4648        )
4649        .await;
4650
4651        assert_eq!(
4652            store.get(id).await.unwrap().needs_review_refires,
4653            1,
4654            "two projects must share one pass of the global NeedsReview sweep"
4655        );
4656    }
4657
4658    /// `tick_all` must not reap tmux sessions it did not start. Startup cleanup used to
4659    /// live here, and nextest's per-test process made the once-flag kill live agents (#3889)
4660    #[tokio::test]
4661    async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4662        let tmux = Arc::new(TmuxManager::new());
4663        let Ok(session_name) = tmux
4664            .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4665            .await
4666        else {
4667            eprintln!("Skipping test: tmux not available or failed to create test session");
4668            return;
4669        };
4670        let _guard = SessionGuard(session_name.clone());
4671
4672        // Cross a unix-second boundary so a reintroduced startup_cleanup would treat
4673        // this session as stale (created_at < now)
4674        time::sleep(Duration::from_millis(1100)).await;
4675
4676        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4677        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4678        let engines = vec![ProjectEngine {
4679            repo: "owner/one".to_string(),
4680            project_dir: PathBuf::from("/nonexistent"),
4681            backend: Arc::clone(&backend),
4682            task_manager: Arc::new(TaskManager::with_store(
4683                backend,
4684                Arc::clone(&store),
4685                "owner/one".to_string(),
4686            )),
4687            runner: Arc::new(TaskRunner::new("owner/one".to_string())),
4688            store,
4689        }];
4690
4691        let transport = Arc::new(Transport::new());
4692        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4693        let (weight_tx, _weight_rx) = mpsc::channel(8);
4694        let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4695
4696        tick_all(
4697            &engines,
4698            &tmux,
4699            &capture,
4700            &Arc::new(Semaphore::new(1)),
4701            &EngineConfig::default(),
4702            &router,
4703            &weight_tx,
4704            &Arc::new(DashMap::new()),
4705            Some(&transport),
4706            &engines.iter().map(|e| e.repo.clone()).collect(),
4707            &Arc::new(DashSet::new()),
4708        )
4709        .await;
4710
4711        assert!(
4712            tmux.session_exists(&session_name).await,
4713            "tick_all must not kill a tmux session it did not start"
4714        );
4715    }
4716
4717    #[tokio::test]
4718    async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4719        let transport = Arc::new(Transport::new());
4720        let capture = Arc::new(CaptureService::new(transport));
4721        let mock = Arc::new(MockBackend::new());
4722        let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4723        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4724        let task_manager = Arc::new(TaskManager::with_store(
4725            Arc::clone(&backend),
4726            Arc::clone(&store),
4727            "owner/repo".to_string(),
4728        ));
4729        let tmux = Arc::new(TmuxManager::new());
4730        let config = EngineConfig {
4731            silence_grace_period: 0,
4732            ..EngineConfig::default()
4733        };
4734
4735        let internal_id = store
4736            .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4737            .await
4738            .unwrap();
4739        store
4740            .update_status(internal_id, TaskStatus::InProgress)
4741            .await
4742            .unwrap();
4743
4744        let task_id = format!("internal:{internal_id}");
4745        capture
4746            .register_session("owner/repo", &task_id, "orch-test-internal")
4747            .await;
4748        time::sleep(Duration::from_millis(1100)).await;
4749
4750        tick_detect_silent_agents(
4751            &tmux,
4752            "owner/repo",
4753            &capture,
4754            &backend,
4755            &task_manager,
4756            &config,
4757            &store,
4758        )
4759        .await
4760        .unwrap();
4761
4762        assert!(
4763            mock.removed_labels.lock().unwrap().is_empty(),
4764            "internal tasks should skip backend label removal"
4765        );
4766        assert!(
4767            mock.posted_comments.lock().unwrap().is_empty(),
4768            "internal tasks should skip backend comments"
4769        );
4770
4771        let task = store.get(internal_id).await.unwrap();
4772        // Silence detection now uses failover: if a fallback agent is found → Routed,
4773        // otherwise → New (reset for re-routing when cooldowns expire). Either is
4774        // acceptable; the key invariant is that the task is no longer InProgress.
4775        assert!(
4776            matches!(task.status, TaskStatus::Routed | TaskStatus::New),
4777            "internal task should be routed to fallback or reset to new, got {:?}",
4778            task.status
4779        );
4780    }
4781
4782    #[tokio::test]
4783    async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4784        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4785        let mock = MockBackend::new();
4786        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4787        let task_manager = Arc::new(TaskManager::with_store(
4788            Arc::clone(&backend),
4789            Arc::clone(&store),
4790            "owner/repo".to_string(),
4791        ));
4792        let transport = Arc::new(Transport::new());
4793        let capture = Arc::new(CaptureService::new(transport));
4794        let tmux = Arc::new(TmuxManager::new());
4795        let config = EngineConfig {
4796            silence_grace_period: 60,
4797            silence_cooldown: 300,
4798            ..EngineConfig::default()
4799        };
4800
4801        let internal_id = store
4802            .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4803            .await
4804            .unwrap();
4805        store
4806            .set_fields(
4807                internal_id,
4808                &[
4809                    ("agent", serde_json::json!("claude")),
4810                    ("model", serde_json::json!("sonnet")),
4811                ],
4812            )
4813            .await
4814            .unwrap();
4815        store
4816            .update_status(internal_id, TaskStatus::InProgress)
4817            .await
4818            .unwrap();
4819
4820        let task_id = format!("internal:{internal_id}");
4821        capture
4822            .register_session("owner/repo", &task_id, "orch-test-silent-run")
4823            .await;
4824        capture
4825            .set_buffer_state_for_test(
4826                "owner/repo",
4827                &task_id,
4828                false,
4829                false,
4830                chrono::Utc::now() - chrono::Duration::seconds(120),
4831            )
4832            .await;
4833
4834        // Start an agent run that will be orphaned by silence detection
4835        store
4836            .start_run(&crate::store::StartRun {
4837                task_id: internal_id,
4838                attempt: 1,
4839                run_type: "agent",
4840                agent: "claude",
4841                model: "sonnet",
4842                command: "claude --model sonnet",
4843                prompt: "fix it",
4844            })
4845            .await
4846            .unwrap();
4847
4848        tick_detect_silent_agents(
4849            &tmux,
4850            "owner/repo",
4851            &capture,
4852            &backend,
4853            &task_manager,
4854            &config,
4855            &store,
4856        )
4857        .await
4858        .unwrap();
4859
4860        let runs = store.get_runs(internal_id).await.unwrap();
4861        assert_eq!(runs.len(), 1);
4862        assert_eq!(runs[0].outcome, "aborted");
4863        assert!(runs[0].completed_at.is_some());
4864        assert_eq!(runs[0].error, "silence detection: session killed");
4865    }
4866
4867    /// Regression test for the race between Phase 1 (tick_check_session_completions)
4868    /// and Phase 2 (tick_recover_stuck_tasks).
4869    ///
4870    /// Before the fix, Phase 1 killed the tmux session without touching updated_at.
4871    /// Phase 2 could then see status=in_progress, has_session=false, and an old
4872    /// updated_at, and would reset the task to New — even though the runner's
4873    /// wait_for_completion() poll had not yet returned.
4874    ///
4875    /// The fix: Phase 1 touches updated_at as soon as it detects the session is done.
4876    /// This test verifies that after the touch, Phase 2 does NOT reclaim the task.
4877    #[tokio::test]
4878    async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4879        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4880        let mock = MockBackend::new();
4881        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4882        let task_manager = Arc::new(TaskManager::with_store(
4883            Arc::clone(&backend),
4884            Arc::clone(&store),
4885            "owner/repo".to_string(),
4886        ));
4887        let tmux = Arc::new(TmuxManager::new());
4888        let config = EngineConfig {
4889            no_session_stuck_timeout: 600,
4890            stuck_timeout: 1800,
4891            ..EngineConfig::default()
4892        };
4893
4894        // Create an in-progress internal task with old updated_at.
4895        // Without the fix this task would be reclaimed by Phase 2 (age >> threshold,
4896        // no session running in the test environment).
4897        let id = store
4898            .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4899            .await
4900            .unwrap();
4901        store
4902            .update_status(id, TaskStatus::InProgress)
4903            .await
4904            .unwrap();
4905        set_task_updated_at_past(&store, id).await;
4906
4907        // Simulate Phase 1: touch updated_at when the session is detected as done.
4908        // In production this happens inside tick_check_session_completions.
4909        let task_id = format!("internal:{id}");
4910        store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4911
4912        // Phase 2 runs — updated_at is fresh so the task must NOT be reclaimed.
4913        tick_recover_stuck_tasks(
4914            &backend,
4915            &tmux,
4916            "owner/repo",
4917            &task_manager,
4918            &config,
4919            &store,
4920            &HashMap::new(),
4921            &Arc::new(DashMap::new()),
4922        )
4923        .await
4924        .unwrap();
4925
4926        let task = store.get(id).await.unwrap();
4927        assert_eq!(
4928            task.status,
4929            TaskStatus::InProgress,
4930            "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4931        );
4932    }
4933
4934    #[tokio::test]
4935    async fn stuck_task_timing_treats_dead_session_as_missing() {
4936        let tmux = Arc::new(TmuxManager::new());
4937        let repo = "owner/repo";
4938        let task_id = "internal:33498";
4939        let session_name = tmux.session_name(repo, task_id);
4940
4941        let create_result = tmux
4942            .command()
4943            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4944            .output()
4945            .await;
4946
4947        match create_result {
4948            Ok(o) if o.status.success() => {}
4949            _ => {
4950                eprintln!("Skipping test: tmux not available or failed to create test session");
4951                return;
4952            }
4953        }
4954
4955        let _guard = SessionGuard(session_name.clone());
4956
4957        let set_option_result = tmux
4958            .command()
4959            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4960            .output()
4961            .await;
4962        if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4963            eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4964            return;
4965        }
4966
4967        let send_exit_result = tmux
4968            .command()
4969            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4970            .output()
4971            .await;
4972        if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4973            eprintln!("Skipping test: unable to exit tmux pane");
4974            return;
4975        }
4976
4977        // Poll until pane is dead (up to 2s) — fixed sleep is unreliable under load.
4978        for _ in 0..20 {
4979            time::sleep(Duration::from_millis(100)).await;
4980            if !tmux.session_is_running(&session_name).await {
4981                break;
4982            }
4983        }
4984
4985        // Also wait for batch_session_active to reflect the dead state.
4986        // session_is_running uses `list-panes -t <session>` which may settle
4987        // before the global `list-panes -a` snapshot used by the production code.
4988        // On CI under load these two tmux commands can transiently disagree.
4989        let mut session_map = tmux.batch_session_active().await;
4990        for _ in 0..20 {
4991            if !session_map.get(&session_name).copied().unwrap_or(false) {
4992                break;
4993            }
4994            time::sleep(Duration::from_millis(100)).await;
4995            session_map = tmux.batch_session_active().await;
4996        }
4997        if session_map.get(&session_name).copied().unwrap_or(false) {
4998            eprintln!(
4999                "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
5000            );
5001            return;
5002        }
5003
5004        let config = EngineConfig {
5005            no_session_stuck_timeout: 600,
5006            stuck_timeout: 1800,
5007            ..EngineConfig::default()
5008        };
5009        let updated_at = (chrono::Utc::now()
5010            - chrono::Duration::seconds(
5011                i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
5012            ))
5013        .to_rfc3339();
5014
5015        let timing = stuck_task_timing_from_map(
5016            &tmux,
5017            repo,
5018            task_id,
5019            &updated_at,
5020            &config,
5021            "parse failure",
5022            &session_map,
5023        )
5024        .expect("dead session should be treated as missing");
5025
5026        assert!(!timing.has_session);
5027        assert_eq!(timing.threshold, config.no_session_stuck_timeout);
5028    }
5029
5030    #[tokio::test]
5031    #[serial_test::serial(suspend_state)]
5032    async fn stuck_task_timing_discounts_host_suspend_gap() {
5033        super::super::suspend::clear_for_test();
5034
5035        let tmux = Arc::new(TmuxManager::new());
5036        let repo = "owner/repo";
5037        let task_id = "internal:99001";
5038        let session_map = HashMap::new();
5039
5040        let config = EngineConfig {
5041            no_session_stuck_timeout: 1800,
5042            stuck_timeout: 1800,
5043            ..EngineConfig::default()
5044        };
5045
5046        // Task was last touched 33 minutes ago (age_mins=33 matches the reported
5047        // false-positive), but the host was suspended for 30 of those minutes —
5048        // real elapsed runtime is only 3 minutes, well under the 30-minute threshold.
5049        let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
5050        let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
5051        super::super::suspend::inject_gap_for_test(
5052            suspend_gap_detected_at,
5053            chrono::Duration::minutes(30),
5054        );
5055
5056        let timing = stuck_task_timing_from_map(
5057            &tmux,
5058            repo,
5059            task_id,
5060            &updated_at,
5061            &config,
5062            "parse failure",
5063            &session_map,
5064        );
5065
5066        assert!(
5067            timing.is_none(),
5068            "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
5069        );
5070
5071        super::super::suspend::clear_for_test();
5072    }
5073
5074    #[tokio::test]
5075    async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
5076        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5077        let mock = MockBackend::new();
5078        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5079        let task_manager = Arc::new(TaskManager::with_store(
5080            Arc::clone(&backend),
5081            Arc::clone(&store),
5082            "owner/repo".to_string(),
5083        ));
5084        let tmux = Arc::new(TmuxManager::new());
5085        let config = EngineConfig {
5086            no_session_stuck_timeout: 600,
5087            stuck_timeout: 1,
5088            ..EngineConfig::default()
5089        };
5090
5091        let id = store
5092            .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
5093            .await
5094            .unwrap();
5095        store.update_status(id, TaskStatus::Routed).await.unwrap();
5096        set_task_updated_at_past(&store, id).await;
5097
5098        let task_id = format!("internal:{id}");
5099        let session_name = tmux.session_name("owner/repo", &task_id);
5100        let create_result = tmux
5101            .command()
5102            .args([
5103                "new-session",
5104                "-d",
5105                "-s",
5106                &session_name,
5107                "-c",
5108                "/tmp",
5109                "sleep 60",
5110            ])
5111            .output()
5112            .await;
5113
5114        match create_result {
5115            Ok(o) if o.status.success() => {}
5116            _ => {
5117                eprintln!("Skipping test: tmux not available or failed to create test session");
5118                return;
5119            }
5120        }
5121
5122        let session_map = tmux.batch_session_active().await;
5123        assert_eq!(session_map.get(&session_name).copied(), Some(true));
5124
5125        tick_recover_stuck_tasks(
5126            &backend,
5127            &tmux,
5128            "owner/repo",
5129            &task_manager,
5130            &config,
5131            &store,
5132            &session_map,
5133            &Arc::new(DashMap::new()),
5134        )
5135        .await
5136        .unwrap();
5137
5138        let task = store.get(id).await.unwrap();
5139        assert_eq!(
5140            task.status,
5141            TaskStatus::New,
5142            "routed task blocked by a stale session should be reset for re-routing"
5143        );
5144        assert!(
5145            !tmux.session_exists(&session_name).await,
5146            "recovery should kill the stale tmux session so dispatch can proceed"
5147        );
5148    }
5149
5150    /// An internal complex task that entered in_progress `minutes` ago and was last written
5151    /// `updated_minutes_ago`, with a live tmux session. None when tmux is not available
5152    async fn live_complex_run(
5153        store: &TaskStore,
5154        tmux: &TmuxManager,
5155        minutes: i64,
5156        updated_minutes_ago: i64,
5157    ) -> Option<(i64, String)> {
5158        let id = store
5159            .create_internal("owner/repo", "Long complex run", "", "cron", "1", None)
5160            .await
5161            .unwrap();
5162        store
5163            .set_fields(id, &[("complexity", serde_json::json!("complex"))])
5164            .await
5165            .unwrap();
5166        store
5167            .update_status(id, TaskStatus::InProgress)
5168            .await
5169            .unwrap();
5170        let ago = |m: i64| {
5171            (chrono::Utc::now() - chrono::Duration::minutes(m))
5172                .format("%Y-%m-%dT%H:%M:%SZ")
5173                .to_string()
5174        };
5175        let entered = ago(minutes);
5176        sqlx::query(
5177            "UPDATE task_activity SET timestamp = ? WHERE task_id = ? AND to_status = 'in_progress'",
5178        )
5179        .bind(&entered)
5180        .bind(id)
5181        .execute(store.pool())
5182        .await
5183        .unwrap();
5184        sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
5185            .bind(ago(updated_minutes_ago))
5186            .bind(id)
5187            .execute(store.pool())
5188            .await
5189            .unwrap();
5190
5191        let session_name = tmux.session_name("owner/repo", &format!("internal:{id}"));
5192        let created = tmux
5193            .command()
5194            .args([
5195                "new-session",
5196                "-d",
5197                "-s",
5198                &session_name,
5199                "-c",
5200                "/tmp",
5201                "sleep 60",
5202            ])
5203            .output()
5204            .await;
5205        match created {
5206            Ok(o) if o.status.success() => Some((id, session_name)),
5207            _ => None,
5208        }
5209    }
5210
5211    fn long_run_config() -> EngineConfig {
5212        EngineConfig {
5213            stuck_timeout: 1800,
5214            run_timeouts: RunTimeouts {
5215                base: 1800,
5216                medium: 2700,
5217                complex: 3600,
5218            },
5219            ..EngineConfig::default()
5220        }
5221    }
5222
5223    #[tokio::test]
5224    async fn recover_stuck_tasks_keeps_a_live_complex_run_inside_its_timeout() {
5225        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5226        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5227        let task_manager = Arc::new(TaskManager::with_store(
5228            Arc::clone(&backend),
5229            Arc::clone(&store),
5230            "owner/repo".to_string(),
5231        ));
5232        let tmux = Arc::new(TmuxManager::new());
5233        let Some((id, session_name)) = live_complex_run(&store, &tmux, 31, 31).await else {
5234            eprintln!("Skipping test: tmux not available or failed to create test session");
5235            return;
5236        };
5237        let session_map = tmux.batch_session_active().await;
5238
5239        tick_recover_stuck_tasks(
5240            &backend,
5241            &tmux,
5242            "owner/repo",
5243            &task_manager,
5244            &long_run_config(),
5245            &store,
5246            &session_map,
5247            &Arc::new(DashMap::new()),
5248        )
5249        .await
5250        .unwrap();
5251
5252        let alive = tmux.session_exists(&session_name).await;
5253        let _ = tmux.kill_session(&session_name).await;
5254        assert_eq!(
5255            store.get(id).await.unwrap().status,
5256            TaskStatus::InProgress,
5257            "a complex run at 31 minutes is inside its 3600s timeout"
5258        );
5259        assert!(alive, "recovery must not touch a run inside its timeout");
5260    }
5261
5262    #[tokio::test]
5263    async fn recover_stuck_tasks_kills_a_live_complex_run_past_its_timeout() {
5264        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5265        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5266        let task_manager = Arc::new(TaskManager::with_store(
5267            Arc::clone(&backend),
5268            Arc::clone(&store),
5269            "owner/repo".to_string(),
5270        ));
5271        let tmux = Arc::new(TmuxManager::new());
5272        let Some((id, session_name)) = live_complex_run(&store, &tmux, 66, 0).await else {
5273            eprintln!("Skipping test: tmux not available or failed to create test session");
5274            return;
5275        };
5276        let session_map = tmux.batch_session_active().await;
5277
5278        tick_recover_stuck_tasks(
5279            &backend,
5280            &tmux,
5281            "owner/repo",
5282            &task_manager,
5283            &long_run_config(),
5284            &store,
5285            &session_map,
5286            &Arc::new(DashMap::new()),
5287        )
5288        .await
5289        .unwrap();
5290
5291        let alive = tmux.session_exists(&session_name).await;
5292        let _ = tmux.kill_session(&session_name).await;
5293        assert_eq!(
5294            store.get(id).await.unwrap().status,
5295            TaskStatus::New,
5296            "a complex run past its timeout plus grace is stuck"
5297        );
5298        assert!(
5299            !alive,
5300            "recovery must kill the session it reports as killed"
5301        );
5302    }
5303
5304    // ── tick_dispatch_tasks: deadlock regression test (#1361) ──────────────
5305
5306    /// Regression test for #1361: tick_dispatch_tasks previously accepted
5307    /// `&Arc<RwLock<Router>>` and called `router_arc.read().await` internally.
5308    /// The main loop already held a write lock on the same RwLock, so the read
5309    /// could never be acquired — guaranteed deadlock on every tick with routed
5310    /// tasks.
5311    ///
5312    /// The fix changed the signature to accept `&Router` directly (the caller
5313    /// already has the dereferenced guard), eliminating the lock re-acquisition.
5314    ///
5315    /// This test verifies the function completes within 2 seconds when the
5316    /// caller holds the write lock — the old code would deadlock here.
5317    #[tokio::test]
5318    async fn dispatch_does_not_deadlock_under_write_lock() {
5319        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5320        let mock = MockBackend::new();
5321        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5322        let task_manager = Arc::new(TaskManager::with_store(
5323            Arc::clone(&backend),
5324            Arc::clone(&store),
5325            "owner/repo".to_string(),
5326        ));
5327        let tmux = Arc::new(TmuxManager::new());
5328        let semaphore = Arc::new(Semaphore::new(4));
5329        let (weight_tx, _weight_rx) = mpsc::channel(16);
5330        let dispatching = Arc::new(DashMap::new());
5331        let transport = Arc::new(Transport::new());
5332        let capture = Arc::new(CaptureService::new(transport));
5333
5334        let router_config = RouterConfig::default();
5335        let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5336        let router_arc = Arc::new(RwLock::new(router));
5337
5338        // Simulate what the main loop does: hold a write lock, snapshot
5339        // dispatch mode, then call tick_dispatch_tasks without passing any
5340        // lock guard into async dispatch work.
5341        let write_guard = router_arc.write().await;
5342        let dispatch_mode = dispatch_mode_from_router(&write_guard);
5343
5344        let result = time::timeout(
5345            Duration::from_secs(2),
5346            tick_dispatch_tasks(
5347                &backend,
5348                &tmux,
5349                "owner/repo",
5350                &Arc::new(TaskRunner::new("owner/repo".to_string())),
5351                &capture,
5352                &semaphore,
5353                &task_manager,
5354                &weight_tx,
5355                dispatch_mode,
5356                &dispatching,
5357                &store,
5358                &HashMap::new(),
5359            ),
5360        )
5361        .await;
5362
5363        assert!(
5364            result.is_ok(),
5365            "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5366             on router_arc while a write lock was already held (issue #1361)"
5367        );
5368    }
5369
5370    /// Regression test for #3599: a task routed to an agent/model that entered
5371    /// cooldown *after* routing but *before* dispatch must not be dispatched —
5372    /// the stale route was decided before the failure that triggered the
5373    /// cooldown was known. Dispatch must re-check the assigned agent/model
5374    /// against the same cooldown store used at routing time and, if stale,
5375    /// leave the task in `Routed` for the next tick to re-evaluate (no PR is
5376    /// spawned, no per-task defer timer is written).
5377    #[tokio::test]
5378    async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5379        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5380        let mock = MockBackend::new();
5381        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5382        let task_manager = Arc::new(TaskManager::with_store(
5383            Arc::clone(&backend),
5384            Arc::clone(&store),
5385            "owner/repo".to_string(),
5386        ));
5387        let tmux = Arc::new(TmuxManager::new());
5388        let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5389        let semaphore = Arc::new(Semaphore::new(4));
5390        let (weight_tx, _weight_rx) = mpsc::channel(16);
5391        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5392        let transport = Arc::new(Transport::new());
5393        let capture = Arc::new(CaptureService::new(transport));
5394
5395        let id = store
5396            .upsert_external(&UpsertExternal {
5397                repo: "owner/repo",
5398                ext_id: "999",
5399                title: "Stale routed task",
5400                body: "",
5401                author: "user",
5402                url: "https://github.com/owner/repo/issues/999",
5403                labels: &[],
5404                origin: "github",
5405            })
5406            .await
5407            .unwrap();
5408        store
5409            .store_route(&StoreRoute {
5410                id,
5411                agent: "minimax",
5412                model: Some("opus"),
5413                complexity: "medium",
5414                estimate: 0,
5415                reason: "test",
5416                profile: "{}",
5417                skills: "[]",
5418            })
5419            .await
5420            .unwrap();
5421        store.update_status(id, DbStatus::Routed).await.unwrap();
5422
5423        // Simulate a billing/rate-limit cooldown recorded for minimax:opus after
5424        // this task was routed but before it dispatched.
5425        cooldown::set_model_cooldown("minimax", "opus", 300, "rate_limit_usage_window").await;
5426
5427        let dispatch_mode = DispatchMode {
5428            is_degraded: false,
5429            healthy_agents: 1,
5430            threshold: 1,
5431        };
5432
5433        tick_dispatch_tasks(
5434            &backend,
5435            &tmux,
5436            "owner/repo",
5437            &runner,
5438            &capture,
5439            &semaphore,
5440            &task_manager,
5441            &weight_tx,
5442            dispatch_mode,
5443            &dispatching,
5444            &store,
5445            &HashMap::new(),
5446        )
5447        .await
5448        .unwrap();
5449
5450        let task = store.get(id).await.unwrap();
5451        assert_eq!(
5452            task.status,
5453            DbStatus::Routed,
5454            "task whose routed model is now cooled down must stay Routed, not be dispatched"
5455        );
5456        assert!(
5457            dispatching.is_empty(),
5458            "dispatch guard must be released when skipping a stale-cooldown task"
5459        );
5460    }
5461
5462    #[tokio::test]
5463    async fn dispatch_skips_a_task_with_an_open_dependency() {
5464        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5465        let mock = MockBackend::new();
5466        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5467        let task_manager = Arc::new(TaskManager::with_store(
5468            Arc::clone(&backend),
5469            Arc::clone(&store),
5470            "owner/repo".to_string(),
5471        ));
5472        let tmux = Arc::new(TmuxManager::new());
5473        let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5474        let semaphore = Arc::new(Semaphore::new(4));
5475        let (weight_tx, _weight_rx) = mpsc::channel(16);
5476        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5477        let transport = Arc::new(Transport::new());
5478        let capture = Arc::new(CaptureService::new(transport));
5479
5480        let id = store
5481            .upsert_external(&UpsertExternal {
5482                repo: "owner/repo",
5483                ext_id: "42",
5484                title: "Waiting routed task",
5485                body: "",
5486                author: "user",
5487                url: "https://github.com/owner/repo/issues/42",
5488                labels: &[],
5489                origin: "github",
5490            })
5491            .await
5492            .unwrap();
5493        store
5494            .replace_dependencies(
5495                id,
5496                &[NewDependency {
5497                    repo: "owner/repo".into(),
5498                    number: 7,
5499                    source: DepSource::Github,
5500                }],
5501            )
5502            .await
5503            .unwrap();
5504        store
5505            .store_route(&StoreRoute {
5506                id,
5507                agent: "claude",
5508                model: Some("haiku"),
5509                complexity: "simple",
5510                estimate: 0,
5511                reason: "test",
5512                profile: "{}",
5513                skills: "[]",
5514            })
5515            .await
5516            .unwrap();
5517        store.update_status(id, DbStatus::Routed).await.unwrap();
5518
5519        let dispatch_mode = DispatchMode {
5520            is_degraded: false,
5521            healthy_agents: 1,
5522            threshold: 1,
5523        };
5524        tick_dispatch_tasks(
5525            &backend,
5526            &tmux,
5527            "owner/repo",
5528            &runner,
5529            &capture,
5530            &semaphore,
5531            &task_manager,
5532            &weight_tx,
5533            dispatch_mode,
5534            &dispatching,
5535            &store,
5536            &HashMap::new(),
5537        )
5538        .await
5539        .unwrap();
5540
5541        let task = store.get(id).await.unwrap();
5542        assert_eq!(task.status, DbStatus::Waiting);
5543        assert!(dispatching.is_empty());
5544    }
5545
5546    /// Regression test for the completed-session -> stuck-task reclaim race.
5547    ///
5548    /// Scenario:
5549    /// - Runner finishes a tmux session and will call store_touch_updated_at shortly after
5550    /// - Engine's tick may observe the dead session in Phase 1 and Phase 2 may run
5551    ///   stuck-task recovery before the runner touches the store, causing incorrect reclaim
5552    /// Fix: tick_check_session_completions now touches the store.updated_at when it
5553    /// observes a completed session, preventing reclaim. This test simulates the
5554    /// timing by creating a session, marking the corresponding task InProgress with
5555    /// an old updated_at, calling tick_check_session_completions (which should touch
5556    /// the store), then calling tick_recover_stuck_tasks and asserting the task
5557    /// remains InProgress.
5558    #[tokio::test]
5559    async fn completed_session_does_not_cause_stuck_reclaim_race() {
5560        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5561        let mock = MockBackend::new();
5562        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5563        let task_manager = Arc::new(TaskManager::with_store(
5564            Arc::clone(&backend),
5565            Arc::clone(&store),
5566            "owner/repo".to_string(),
5567        ));
5568        let tmux = Arc::new(TmuxManager::new());
5569        let transport = Arc::new(Transport::new());
5570        let capture = Arc::new(CaptureService::new(transport));
5571
5572        // Insert external task and set to InProgress with old updated_at
5573        let id = store
5574            .upsert_external(&crate::store::UpsertExternal {
5575                repo: "owner/repo",
5576                ext_id: "200",
5577                title: "Race Task",
5578                body: "",
5579                author: "",
5580                url: "",
5581                labels: &[],
5582                origin: "github",
5583            })
5584            .await
5585            .unwrap();
5586        store
5587            .update_status(id, TaskStatus::InProgress)
5588            .await
5589            .unwrap();
5590
5591        // set updated_at to far past so it would normally be eligible for reclaim
5592        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5593            .bind(id)
5594            .execute(store.pool())
5595            .await
5596            .unwrap();
5597
5598        // Register a real tmux session for this task and then exit it so it's observed dead
5599        let task_id = "200";
5600        let session_name = tmux.session_name("owner/repo", task_id);
5601        let create_result = tmux
5602            .command()
5603            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5604            .output()
5605            .await;
5606        match create_result {
5607            Ok(o) if o.status.success() => {}
5608            _ => {
5609                eprintln!("Skipping test: tmux not available or failed to create test session");
5610                return;
5611            }
5612        }
5613        let _guard = SessionGuard(session_name.clone());
5614
5615        // Ensure remain-on-exit so the session lingers as dead long enough to be snapshot
5616        let _ = tmux
5617            .command()
5618            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5619            .output()
5620            .await;
5621        let _ = tmux
5622            .command()
5623            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5624            .output()
5625            .await;
5626
5627        // Poll until pane is dead
5628        for _ in 0..20 {
5629            time::sleep(Duration::from_millis(100)).await;
5630            if !tmux.session_is_running(&session_name).await {
5631                break;
5632            }
5633        }
5634
5635        // Register the session in capture (engine does this during dispatch)
5636        let task_key = task_id.to_string();
5637        capture
5638            .register_session("owner/repo", &task_key, &session_name)
5639            .await;
5640
5641        // Call phase1 (now touches store.updated_at for completed session)
5642        let config = EngineConfig {
5643            no_session_stuck_timeout: 600,
5644            stuck_timeout: 1800,
5645            ..EngineConfig::default()
5646        };
5647
5648        tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5649            .await
5650            .unwrap();
5651
5652        // Verify phase1 actually touched updated_at. It only processes sessions
5653        // that appear in snapshot() (list_sessions + batch_session_active). On CI,
5654        // if the session was already fully removed before the snapshot runs (e.g.
5655        // remain-on-exit semantics differ across tmux versions), phase1 won't see
5656        // it and the store won't be touched. Skip in that case.
5657        {
5658            let task_state = store.get(id).await.unwrap();
5659            if task_state.updated_at.starts_with("2020") {
5660                eprintln!(
5661                    "Skipping test: tick_check_session_completions did not update stored \
5662                     updated_at — session was not in snapshot (CI tmux environment issue)"
5663                );
5664                return;
5665            }
5666        }
5667
5668        // Now run phase2 — the updated_at should have been touched so no reclaim occurs
5669        tick_recover_stuck_tasks(
5670            &backend,
5671            &tmux,
5672            "owner/repo",
5673            &task_manager,
5674            &config,
5675            &store,
5676            &HashMap::new(),
5677            &Arc::new(DashMap::new()),
5678        )
5679        .await
5680        .unwrap();
5681
5682        // Verify the task is still InProgress in the store
5683        let task = store.get(id).await.unwrap();
5684        assert_eq!(
5685            task.status,
5686            TaskStatus::InProgress,
5687            "task should not be reclaimed to New"
5688        );
5689    }
5690
5691    /// Regression test: a repo configured with a ".git" suffix (e.g. "owner/repo.git")
5692    /// must still match its own live tmux sessions in `tick_check_session_completions`.
5693    ///
5694    /// `TmuxManager::session_name` strips ".git" when creating the session, so a live
5695    /// session's parsed `project` field never carries it. Before the fix, this
5696    /// function's `repo_name` derivation skipped the `.trim_end_matches(".git")` step,
5697    /// so `session.project` ("repo") never equaled `repo_name` ("repo.git") and the
5698    /// completed-session touch/kill was silently skipped for the entire repo — leaving
5699    /// the coarser-grained stuck-task reclaim as the only (racy) safety net.
5700    #[tokio::test]
5701    async fn session_completion_matches_repo_configured_with_git_suffix() {
5702        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5703        let tmux = Arc::new(TmuxManager::new());
5704        let transport = Arc::new(Transport::new());
5705        let capture = Arc::new(CaptureService::new(transport));
5706
5707        let repo = "owner/repo.git";
5708        let id = store
5709            .upsert_external(&crate::store::UpsertExternal {
5710                repo,
5711                ext_id: "201",
5712                title: "Git-suffix repo task",
5713                body: "",
5714                author: "",
5715                url: "",
5716                labels: &[],
5717                origin: "github",
5718            })
5719            .await
5720            .unwrap();
5721        store
5722            .update_status(id, TaskStatus::InProgress)
5723            .await
5724            .unwrap();
5725        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5726            .bind(id)
5727            .execute(store.pool())
5728            .await
5729            .unwrap();
5730
5731        // session_name strips ".git" internally — this is the real session a dispatch
5732        // for this repo would have created.
5733        let task_id = "201";
5734        let session_name = tmux.session_name(repo, task_id);
5735        let create_result = tmux
5736            .command()
5737            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5738            .output()
5739            .await;
5740        match create_result {
5741            Ok(o) if o.status.success() => {}
5742            _ => {
5743                eprintln!("Skipping test: tmux not available or failed to create test session");
5744                return;
5745            }
5746        }
5747        let _guard = SessionGuard(session_name.clone());
5748
5749        let _ = tmux
5750            .command()
5751            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5752            .output()
5753            .await;
5754        let _ = tmux
5755            .command()
5756            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5757            .output()
5758            .await;
5759
5760        for _ in 0..20 {
5761            time::sleep(Duration::from_millis(100)).await;
5762            if !tmux.session_is_running(&session_name).await {
5763                break;
5764            }
5765        }
5766
5767        capture.register_session(repo, task_id, &session_name).await;
5768
5769        tick_check_session_completions(&tmux, repo, &capture, &store)
5770            .await
5771            .unwrap();
5772
5773        let task_state = store.get(id).await.unwrap();
5774        if task_state.updated_at.starts_with("2020") {
5775            eprintln!(
5776                "Skipping test: tick_check_session_completions did not update stored \
5777                 updated_at — session was not in snapshot (CI tmux environment issue)"
5778            );
5779            return;
5780        }
5781        assert!(
5782            !task_state.updated_at.starts_with("2020"),
5783            "tick_check_session_completions must match its own repo's live sessions \
5784             even when the repo is configured with a \".git\" suffix"
5785        );
5786    }
5787
5788    // ── tick_job_scheduler: resilience to bad project configs ────────────────
5789
5790    /// A project with a duplicate job id (same id in .orch.yml AND prompts/jobs/)
5791    /// must not cause tick_job_scheduler to return Err — it should log an error
5792    /// and return Ok(()) so other projects are unaffected.
5793    #[tokio::test]
5794    async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5795        let tmp = tempfile::tempdir().unwrap();
5796        let cfg = tmp.path().join(".orch.yml");
5797        fs::write(
5798            &cfg,
5799            "jobs:\n  - id: trading-scan\n    schedule: \"0 9 * * *\"\n    task:\n      title: Scan\n      body: do it\n",
5800        )
5801        .unwrap();
5802        let jobs_dir = tmp.path().join("prompts").join("jobs");
5803        fs::create_dir_all(&jobs_dir).unwrap();
5804        fs::write(
5805            jobs_dir.join("trading-scan.md"),
5806            "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5807        )
5808        .unwrap();
5809
5810        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5811        let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5812        assert!(
5813            result.is_ok(),
5814            "duplicate job id must not propagate as Err: {result:?}"
5815        );
5816    }
5817
5818    /// When one project has a duplicate job id (bad config) and a second project
5819    /// has a valid config, the scheduler tick for the second project must still
5820    /// succeed — simulating the per-project loop in the engine.
5821    #[tokio::test]
5822    async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5823        // Bad project: duplicate id across .orch.yml and prompts/jobs/
5824        let bad_tmp = tempfile::tempdir().unwrap();
5825        let bad_cfg = bad_tmp.path().join(".orch.yml");
5826        fs::write(
5827            &bad_cfg,
5828            "jobs:\n  - id: dup\n    schedule: \"0 9 * * *\"\n    task:\n      title: Dup\n      body: x\n",
5829        )
5830        .unwrap();
5831        let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5832        fs::create_dir_all(&bad_jobs_dir).unwrap();
5833        fs::write(
5834            bad_jobs_dir.join("dup.md"),
5835            "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5836        )
5837        .unwrap();
5838
5839        // Good project: valid config, no duplicate
5840        let good_tmp = tempfile::tempdir().unwrap();
5841        let good_cfg = good_tmp.path().join(".orch.yml");
5842        fs::write(
5843            &good_cfg,
5844            "jobs:\n  - id: valid-job\n    schedule: \"0 9 * * *\"\n    task:\n      title: Valid\n      body: ok\n",
5845        )
5846        .unwrap();
5847
5848        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5849
5850        let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5851        assert!(
5852            bad_result.is_ok(),
5853            "bad project must not abort scheduler: {bad_result:?}"
5854        );
5855
5856        let good_result =
5857            tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5858        assert!(
5859            good_result.is_ok(),
5860            "valid project must still run after bad project: {good_result:?}"
5861        );
5862    }
5863
5864    // ── route_new_tasks_global ───────────────────────────────────────────────
5865
5866    /// Regression test for #3407: tasks from inactive/removed repos were stuck
5867    /// in `new` indefinitely because `tick_route_tasks` is repo-scoped.
5868    ///
5869    /// `route_new_tasks_global` must route tasks whose repo != current_repo
5870    /// and leave current-repo tasks untouched.
5871    #[serial_test::serial(cooldown_state)]
5872    #[tokio::test]
5873    async fn global_sweep_routes_inactive_repo_tasks() {
5874        cooldown::reset_global_state().await;
5875
5876        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5877
5878        // Active repo task — must NOT be touched by the global sweep.
5879        let active_id = store
5880            .upsert_external(&UpsertExternal {
5881                repo: "active/repo",
5882                ext_id: "1",
5883                title: "Active task",
5884                body: "",
5885                author: "user",
5886                url: "https://github.com/active/repo/issues/1",
5887                labels: &[],
5888                origin: "github",
5889            })
5890            .await
5891            .unwrap();
5892
5893        // Inactive repo task — must be routed by the global sweep.
5894        let inactive_id = store
5895            .upsert_external(&UpsertExternal {
5896                repo: "inactive/repo",
5897                ext_id: "42",
5898                title: "Orphaned task",
5899                body: "",
5900                author: "user",
5901                url: "https://github.com/inactive/repo/issues/42",
5902                labels: &[],
5903                origin: "github",
5904            })
5905            .await
5906            .unwrap();
5907
5908        assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5909        assert_eq!(
5910            store.get(inactive_id).await.unwrap().status,
5911            TaskStatus::New
5912        );
5913
5914        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5915        let mut tier = HashMap::new();
5916        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5917        model_map.insert("medium".to_string(), tier);
5918        let config = RouterConfig {
5919            mode: "round_robin".to_string(),
5920            agents: vec!["claude".to_string()],
5921            model_map,
5922            ..Default::default()
5923        };
5924        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5925
5926        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
5927        let mut routing_quota = RoutingTickQuota::new(1);
5928        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5929            .await
5930            .unwrap();
5931
5932        // Active-repo task must remain new (global sweep skips it).
5933        assert_eq!(
5934            store.get(active_id).await.unwrap().status,
5935            TaskStatus::New,
5936            "global sweep must not touch tasks for the active repo"
5937        );
5938
5939        // Inactive-repo task must now be routed.
5940        let after = store.get(inactive_id).await.unwrap();
5941        assert_eq!(
5942            after.status,
5943            TaskStatus::Routed,
5944            "global sweep must route tasks from inactive repos"
5945        );
5946        assert!(
5947            matches!(&after.agent, Some(a) if !a.is_empty()),
5948            "agent must be set after routing"
5949        );
5950    }
5951
5952    /// Regression test for #3523: `route_new_tasks_global` previously filtered only
5953    /// `t.repo != current_repo`, so during repo A's tick it would route repo B's `new`
5954    /// tasks too — even though B is an active, non-orphaned project with its own tick
5955    /// scheduled later in the same pass. This is the exact "racing the real per-repo
5956    /// dispatch" failure mode `dispatch_routed_tasks_global` was fixed for in #3415;
5957    /// the route-side sweep needs the same `active_repos` scoping, not just the
5958    /// current tick's own repo.
5959    #[serial_test::serial(cooldown_state)]
5960    #[tokio::test]
5961    async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5962        cooldown::reset_global_state().await;
5963
5964        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5965
5966        // Belongs to a second active repo — NOT the repo whose tick is running,
5967        // but still a currently-configured project, not an orphan.
5968        let other_active_id = store
5969            .upsert_external(&UpsertExternal {
5970                repo: "other-active/repo",
5971                ext_id: "7",
5972                title: "Other active project's task",
5973                body: "",
5974                author: "user",
5975                url: "https://github.com/other-active/repo/issues/7",
5976                labels: &[],
5977                origin: "github",
5978            })
5979            .await
5980            .unwrap();
5981
5982        // Genuinely orphaned repo — not in the active set at all.
5983        let inactive_id = store
5984            .upsert_external(&UpsertExternal {
5985                repo: "inactive/repo",
5986                ext_id: "42",
5987                title: "Orphaned task",
5988                body: "",
5989                author: "user",
5990                url: "https://github.com/inactive/repo/issues/42",
5991                labels: &[],
5992                origin: "github",
5993            })
5994            .await
5995            .unwrap();
5996
5997        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5998        let mut tier = HashMap::new();
5999        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6000        model_map.insert("medium".to_string(), tier);
6001        let config = RouterConfig {
6002            mode: "round_robin".to_string(),
6003            agents: vec!["claude".to_string()],
6004            model_map,
6005            ..Default::default()
6006        };
6007        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6008
6009        // Simulate "active/repo"'s tick running the global sweep. Both
6010        // "active/repo" and "other-active/repo" are currently-configured
6011        // projects; only "inactive/repo" is orphaned.
6012        let active_repos: HashSet<String> =
6013            ["active/repo".to_string(), "other-active/repo".to_string()]
6014                .into_iter()
6015                .collect();
6016        let mut routing_quota = RoutingTickQuota::new(1);
6017        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6018            .await
6019            .unwrap();
6020
6021        assert_eq!(
6022            store.get(other_active_id).await.unwrap().status,
6023            TaskStatus::New,
6024            "global sweep run from a different active repo's tick must not route \
6025             another active repo's new tasks — that repo's own tick will route them"
6026        );
6027        assert_eq!(
6028            store.get(inactive_id).await.unwrap().status,
6029            TaskStatus::Routed,
6030            "global sweep must still route tasks from genuinely orphaned repos"
6031        );
6032    }
6033
6034    /// Tasks with the `no-agent` label must be skipped by the global sweep.
6035    #[serial_test::serial(cooldown_state)]
6036    #[tokio::test]
6037    async fn global_sweep_skips_no_agent_tasks() {
6038        cooldown::reset_global_state().await;
6039
6040        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6041
6042        let id = store
6043            .upsert_external(&UpsertExternal {
6044                repo: "inactive/repo",
6045                ext_id: "99",
6046                title: "No-agent task",
6047                body: "",
6048                author: "user",
6049                url: "https://github.com/inactive/repo/issues/99",
6050                labels: &["no-agent".to_string()],
6051                origin: "github",
6052            })
6053            .await
6054            .unwrap();
6055
6056        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6057        let mut tier = HashMap::new();
6058        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6059        model_map.insert("medium".to_string(), tier);
6060        let config = RouterConfig {
6061            mode: "round_robin".to_string(),
6062            agents: vec!["claude".to_string()],
6063            model_map,
6064            ..Default::default()
6065        };
6066        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6067
6068        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6069        let mut routing_quota = RoutingTickQuota::new(1);
6070        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6071            .await
6072            .unwrap();
6073
6074        assert_eq!(
6075            store.get(id).await.unwrap().status,
6076            TaskStatus::New,
6077            "no-agent tasks must not be routed"
6078        );
6079    }
6080
6081    /// Regression test for #3615: the routing limit must be consumed by a
6082    /// shared per-engine-tick quota, not independently by every repo/global
6083    /// routing pass. Otherwise one slow router timeout per pass can stack into
6084    /// a multi-minute watchdog stall.
6085    #[serial_test::serial(cooldown_state)]
6086    #[tokio::test]
6087    async fn global_sweep_honors_shared_routing_quota() {
6088        cooldown::reset_global_state().await;
6089
6090        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6091        for ext_id in ["1", "2"] {
6092            store
6093                .upsert_external(&UpsertExternal {
6094                    repo: "inactive/repo",
6095                    ext_id,
6096                    title: "Orphaned task",
6097                    body: "",
6098                    author: "user",
6099                    url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
6100                    labels: &[],
6101                    origin: "github",
6102                })
6103                .await
6104                .unwrap();
6105        }
6106
6107        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6108        let mut tier = HashMap::new();
6109        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6110        model_map.insert("medium".to_string(), tier);
6111        let config = RouterConfig {
6112            mode: "round_robin".to_string(),
6113            agents: vec!["claude".to_string()],
6114            model_map,
6115            ..Default::default()
6116        };
6117        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6118
6119        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6120        let mut routing_quota = RoutingTickQuota::new(1);
6121        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6122            .await
6123            .unwrap();
6124
6125        let all_tasks = store.list_all_global().await.unwrap();
6126        let routed = all_tasks
6127            .iter()
6128            .filter(|task| task.status == TaskStatus::Routed)
6129            .count();
6130        let still_new = all_tasks
6131            .iter()
6132            .filter(|task| task.status == TaskStatus::New)
6133            .count();
6134
6135        assert_eq!(routed, 1, "only one task may consume the shared quota");
6136        assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
6137
6138        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6139            .await
6140            .unwrap();
6141        let after_exhausted = store.list_all_global().await.unwrap();
6142        assert_eq!(
6143            after_exhausted
6144                .iter()
6145                .filter(|task| task.status == TaskStatus::Routed)
6146                .count(),
6147            1,
6148            "an exhausted shared quota must not reset within the same tick"
6149        );
6150    }
6151
6152    // ── dispatch_routed_tasks_global ─────────────────────────────────────────
6153    //
6154    // `dispatch_routed_tasks_global` itself is deliberately not exercised
6155    // end-to-end here: once it finds an orphaned repo, it spawns the real
6156    // runner, which creates a real tmux session and touches the network.
6157    // The existing `dispatch_does_not_deadlock_under_write_lock` test avoids
6158    // this same hazard by keeping the dispatchable set empty. The selection
6159    // logic (which tasks belong to which repo) is pulled into the pure
6160    // `group_orphaned_routed_tasks` helper below so it can be tested without
6161    // those side effects.
6162
6163    /// Regression test for #3413: tasks routed for an inactive/removed repo were
6164    /// stuck in `routed` indefinitely because `tick_dispatch_tasks` is repo-scoped.
6165    ///
6166    /// `group_orphaned_routed_tasks` must select tasks whose repo != the active
6167    /// repo, group them by repo, and leave active-repo tasks out entirely.
6168    #[tokio::test]
6169    async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
6170        async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6171            let id = store
6172                .upsert_external(&UpsertExternal {
6173                    repo,
6174                    ext_id,
6175                    title: "Routed task",
6176                    body: "",
6177                    author: "user",
6178                    url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6179                    labels: &[],
6180                    origin: "github",
6181                })
6182                .await
6183                .unwrap();
6184            store
6185                .store_route(&StoreRoute {
6186                    id,
6187                    agent: "claude",
6188                    model: Some("sonnet"),
6189                    complexity: "medium",
6190                    estimate: 0,
6191                    reason: "test",
6192                    profile: "{}",
6193                    skills: "[]",
6194                })
6195                .await
6196                .unwrap();
6197            store.update_status(id, TaskStatus::Routed).await.unwrap();
6198            id
6199        }
6200
6201        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6202
6203        let active_id = make_routed(&store, "active/repo", "1").await;
6204        let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
6205        let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6206        let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6207
6208        let all_routed = store
6209            .list_all_by_status_global(TaskStatus::Routed)
6210            .await
6211            .unwrap();
6212        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6213        let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6214
6215        assert!(
6216            !by_repo.contains_key("active/repo"),
6217            "global sweep must not select tasks for the active repo"
6218        );
6219        let inactive_ids: HashSet<i64> = by_repo
6220            .get("inactive/repo")
6221            .expect("inactive/repo must be selected")
6222            .iter()
6223            .map(|t| t.id)
6224            .collect();
6225        assert_eq!(
6226            inactive_ids,
6227            [inactive_id_a, inactive_id_b].into_iter().collect(),
6228            "global sweep must group all orphaned tasks for a repo together"
6229        );
6230        assert_eq!(
6231            by_repo
6232                .get("other-inactive/repo")
6233                .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6234            Some(vec![other_inactive_id])
6235        );
6236
6237        // Sanity: the active-repo task really was created and routed, it's just
6238        // excluded from the selection above.
6239        assert_eq!(
6240            store.get(active_id).await.unwrap().status,
6241            TaskStatus::Routed
6242        );
6243    }
6244
6245    /// Regression test for the PR #3413 review finding: a single `current_repo` string is
6246    /// the wrong exclusion — repo B's routed tasks must not look "orphaned" during repo A's
6247    /// dispatch just because A != B.
6248    /// `group_orphaned_routed_tasks` must exclude every repo in the full active set.
6249    #[tokio::test]
6250    async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6251        async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6252            let id = store
6253                .upsert_external(&UpsertExternal {
6254                    repo,
6255                    ext_id,
6256                    title: "Routed task",
6257                    body: "",
6258                    author: "user",
6259                    url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6260                    labels: &[],
6261                    origin: "github",
6262                })
6263                .await
6264                .unwrap();
6265            store
6266                .store_route(&StoreRoute {
6267                    id,
6268                    agent: "claude",
6269                    model: Some("sonnet"),
6270                    complexity: "medium",
6271                    estimate: 0,
6272                    reason: "test",
6273                    profile: "{}",
6274                    skills: "[]",
6275                })
6276                .await
6277                .unwrap();
6278            store.update_status(id, TaskStatus::Routed).await.unwrap();
6279            id
6280        }
6281
6282        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6283
6284        let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6285        let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6286        let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6287
6288        let all_routed = store
6289            .list_all_by_status_global(TaskStatus::Routed)
6290            .await
6291            .unwrap();
6292        // Simulate repo A's tick: current repo is "active/repo-a", but the full
6293        // active project set also includes "active/repo-b".
6294        let active_repos: HashSet<String> =
6295            ["active/repo-a".to_string(), "active/repo-b".to_string()]
6296                .into_iter()
6297                .collect();
6298        let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6299
6300        assert!(
6301            !by_repo.contains_key("active/repo-a"),
6302            "the current repo's own tasks must not be swept"
6303        );
6304        assert!(
6305            !by_repo.contains_key("active/repo-b"),
6306            "another active repo's tasks must not be swept during repo A's tick"
6307        );
6308        assert_eq!(
6309            by_repo
6310                .get("inactive/repo")
6311                .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6312            Some(vec![orphaned_id]),
6313            "a genuinely inactive repo's tasks must still be swept"
6314        );
6315
6316        // Sanity: repo A and repo B tasks were really created and routed, just excluded.
6317        assert_eq!(
6318            store.get(repo_a_id).await.unwrap().status,
6319            TaskStatus::Routed
6320        );
6321        assert_eq!(
6322            store.get(repo_b_id).await.unwrap().status,
6323            TaskStatus::Routed
6324        );
6325    }
6326
6327    /// Tasks with the `no-agent` label must be skipped by the global dispatch sweep.
6328    #[tokio::test]
6329    async fn global_dispatch_sweep_skips_no_agent_tasks() {
6330        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6331
6332        let id = store
6333            .upsert_external(&UpsertExternal {
6334                repo: "inactive/repo",
6335                ext_id: "99",
6336                title: "No-agent routed task",
6337                body: "",
6338                author: "user",
6339                url: "https://github.com/inactive/repo/issues/99",
6340                labels: &["no-agent".to_string()],
6341                origin: "github",
6342            })
6343            .await
6344            .unwrap();
6345        store
6346            .store_route(&StoreRoute {
6347                id,
6348                agent: "claude",
6349                model: Some("sonnet"),
6350                complexity: "medium",
6351                estimate: 0,
6352                reason: "test",
6353                profile: "{}",
6354                skills: "[]",
6355            })
6356            .await
6357            .unwrap();
6358        store.update_status(id, TaskStatus::Routed).await.unwrap();
6359
6360        // Safe to call the full sweep here: with the only orphaned task filtered
6361        // out by the no-agent label, `group_orphaned_routed_tasks` returns an
6362        // empty map and `dispatch_routed_tasks_global` returns before ever
6363        // constructing a backend or spawning a runner.
6364        let tmux = Arc::new(TmuxManager::new());
6365        let transport = Arc::new(Transport::new());
6366        let capture = Arc::new(CaptureService::new(transport));
6367        let semaphore = Arc::new(Semaphore::new(4));
6368        let (weight_tx, _weight_rx) = mpsc::channel(16);
6369        let dispatching = Arc::new(DashMap::new());
6370        let dispatch_mode = DispatchMode {
6371            is_degraded: false,
6372            healthy_agents: 1,
6373            threshold: 1,
6374        };
6375
6376        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6377        dispatch_routed_tasks_global(
6378            &tmux,
6379            &capture,
6380            &semaphore,
6381            &weight_tx,
6382            dispatch_mode,
6383            &dispatching,
6384            &store,
6385            &HashMap::new(),
6386            &active_repos,
6387        )
6388        .await
6389        .unwrap();
6390
6391        assert_eq!(
6392            store.get(id).await.unwrap().status,
6393            TaskStatus::Routed,
6394            "no-agent tasks must not be dispatched"
6395        );
6396    }
6397}