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
3112fn display_status_for(signal: &WeightSignal, enable_review: bool, has_pr: bool) -> &'static str {
3113    match signal {
3114        WeightSignal::Success { .. } if enable_review => {
3115            if has_pr {
3116                "needs_review"
3117            } else {
3118                "done"
3119            }
3120        }
3121        // Silence detection or another non-rate-limit reset already moved the task to "new".
3122        // Converting it to needs_review would trigger the no-code shortcut and falsely close it
3123        WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
3124        WeightSignal::Blocked => "blocked",
3125        // The review path re-routes a task with no PR and no commits
3126        WeightSignal::Failed { .. } => "needs_review",
3127        // None means the runner guard skipped the task or the status was unrecognised
3128        WeightSignal::None if enable_review => "needs_review",
3129        WeightSignal::Success { .. } | WeightSignal::None => "done",
3130    }
3131}
3132
3133#[cfg(test)]
3134mod tests {
3135    use super::*;
3136    use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3137    use crate::channels::transport::Transport;
3138    use crate::engine::RunTimeouts;
3139    use crate::engine::cooldown;
3140    use crate::engine::router::Router;
3141    use crate::engine::router::RouterConfig;
3142    use crate::engine::tasks::TaskManager;
3143    use crate::store::DepSource;
3144    use crate::store::NewDependency;
3145    use crate::store::NewTask;
3146    use crate::store::StoreRoute;
3147    use crate::store::TaskStatus as DbStatus;
3148    use crate::store::TaskStatus;
3149    use crate::store::TaskStore;
3150    use crate::store::UpsertExternal;
3151    use crate::tmux::{SessionGuard, TmuxManager};
3152    use async_trait::async_trait;
3153    use dashmap::mapref::entry::Entry;
3154    use std::fs;
3155    use std::path::PathBuf;
3156    use std::sync::{Arc, Mutex};
3157    use tokio::sync::RwLock;
3158    use tokio::sync::oneshot;
3159    use tokio::task;
3160
3161    #[test]
3162    fn timed_out_run_without_pr_never_becomes_done() {
3163        let failed = WeightSignal::Failed {
3164            agent: "claude".to_string(),
3165        };
3166        assert_eq!(display_status_for(&failed, true, false), "needs_review");
3167        assert_eq!(display_status_for(&failed, false, false), "needs_review");
3168    }
3169
3170    #[test]
3171    fn done_needs_a_successful_run_with_no_pr() {
3172        let ok = WeightSignal::Success {
3173            agent: "claude".to_string(),
3174        };
3175        assert_eq!(display_status_for(&ok, true, true), "needs_review");
3176        assert_eq!(display_status_for(&ok, true, false), "done");
3177        assert_eq!(
3178            display_status_for(&WeightSignal::Rerouted, true, false),
3179            "new"
3180        );
3181        assert_eq!(
3182            display_status_for(&WeightSignal::None, true, false),
3183            "needs_review"
3184        );
3185    }
3186    #[test]
3187    fn skips_agent_for_human_and_no_agent_labels() {
3188        assert!(skips_agent(&["owner:human".to_string()]));
3189        assert!(skips_agent(&["no-agent".to_string()]));
3190        assert!(!skips_agent(&["status:new".to_string()]));
3191    }
3192
3193    /// A router reporting one available agent with no healthy "review" model, so the
3194    /// global NeedsReview refire sweep in `tick_global_sweeps` treats every review agent
3195    /// as cooled and skips — keeping it a no-op for tests unrelated to it.
3196    fn cooled_review_router_for_test() -> Router {
3197        Router::new_for_test(RouterConfig::default(), vec!["claude".to_string()])
3198    }
3199
3200    // ── minimal mock backend ─────────────────────────────────────────────────
3201
3202    /// Configurable mock backend for tick tests.
3203    struct MockBackend {
3204        /// Tasks returned by `list_by_status(Blocked)`.
3205        blocked_tasks: Vec<ExternalTask>,
3206        /// Sub-issues map: task_id → list of child ExternalIds.
3207        sub_issues: HashMap<String, Vec<ExternalId>>,
3208        /// Tasks returned by `get_task`. Keyed by id.0.
3209        tasks_by_id: HashMap<String, ExternalTask>,
3210        /// Recorded `get_sub_issues` calls.
3211        get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3212        /// Recorded `update_status` calls.
3213        status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3214        /// Recorded `remove_label` calls.
3215        removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3216        /// Recorded `post_comment` calls.
3217        posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3218    }
3219
3220    impl MockBackend {
3221        fn new() -> Self {
3222            Self {
3223                blocked_tasks: vec![],
3224                sub_issues: HashMap::new(),
3225                tasks_by_id: HashMap::new(),
3226                get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3227                status_updates: Arc::new(Mutex::new(vec![])),
3228                removed_labels: Arc::new(Mutex::new(vec![])),
3229                posted_comments: Arc::new(Mutex::new(vec![])),
3230            }
3231        }
3232    }
3233
3234    fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3235        ExternalTask {
3236            id: ExternalId(id.to_string()),
3237            title: format!("Task {id}"),
3238            body: String::new(),
3239            state: "open".to_string(),
3240            labels: labels.iter().map(ToString::to_string).collect(),
3241            author: "bot".to_string(),
3242            created_at: "2026-01-01T00:00:00Z".to_string(),
3243            updated_at: "2026-01-01T00:00:00Z".to_string(),
3244            url: format!("https://github.com/test/test/issues/{id}"),
3245        }
3246    }
3247
3248    #[async_trait]
3249    impl ExternalBackend for MockBackend {
3250        fn name(&self) -> &'static str {
3251            "mock"
3252        }
3253        async fn create_task(
3254            &self,
3255            _title: &str,
3256            _body: &str,
3257            _labels: &[String],
3258        ) -> anyhow::Result<ExternalId> {
3259            Ok(ExternalId("new".to_string()))
3260        }
3261        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3262            self.tasks_by_id
3263                .get(&id.0)
3264                .cloned()
3265                .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3266        }
3267        async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3268            if status == Status::Blocked {
3269                Ok(self.blocked_tasks.clone())
3270            } else {
3271                Ok(vec![])
3272            }
3273        }
3274        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3275            Ok(vec![])
3276        }
3277        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3278            self.posted_comments
3279                .lock()
3280                .unwrap()
3281                .push((_id.0.clone(), _body.to_string()));
3282            Ok(())
3283        }
3284        async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3285            Ok(())
3286        }
3287        async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3288            self.removed_labels
3289                .lock()
3290                .unwrap()
3291                .push((_id.0.clone(), _label.to_string()));
3292            Ok(())
3293        }
3294        async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3295            self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3296            Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3297        }
3298        async fn create_sub_task(
3299            &self,
3300            _parent: &ExternalId,
3301            _title: &str,
3302            _body: &str,
3303            _labels: &[String],
3304        ) -> anyhow::Result<ExternalId> {
3305            Ok(ExternalId("child".to_string()))
3306        }
3307        async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3308            Ok(())
3309        }
3310        async fn has_open_issue_with_title(
3311            &self,
3312            _title: &str,
3313            _label: &str,
3314        ) -> anyhow::Result<bool> {
3315            Ok(false)
3316        }
3317        async fn health_check(&self) -> anyhow::Result<()> {
3318            Ok(())
3319        }
3320        async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3321            Ok(false)
3322        }
3323        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3324            Ok(Some("testbot".to_string()))
3325        }
3326        async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3327            Ok(vec![])
3328        }
3329        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3330            self.status_updates
3331                .lock()
3332                .unwrap()
3333                .push((id.0.clone(), status));
3334            Ok(())
3335        }
3336    }
3337
3338    fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3339        Arc::new(TaskManager::new(backend))
3340    }
3341
3342    // ── tick_unblock_parents ─────────────────────────────────────────────────
3343
3344    #[tokio::test]
3345    async fn unblock_parents_unblocks_when_all_children_done() {
3346        let mut mock = MockBackend::new();
3347
3348        // Blocked parent with two done children
3349        let parent = make_task("10", &["status:blocked"]);
3350        mock.blocked_tasks.push(parent.clone());
3351        mock.sub_issues.insert(
3352            "10".to_string(),
3353            vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3354        );
3355        mock.tasks_by_id
3356            .insert("11".to_string(), make_task("11", &["status:done"]));
3357        mock.tasks_by_id
3358            .insert("12".to_string(), make_task("12", &["status:done"]));
3359
3360        let status_updates = Arc::clone(&mock.status_updates);
3361        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3362        let task_manager = make_task_manager(Arc::clone(&backend));
3363        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3364
3365        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3366            .await
3367            .unwrap();
3368
3369        let updates = status_updates.lock().unwrap();
3370        assert_eq!(updates.len(), 1, "parent should be unblocked");
3371        assert_eq!(updates[0], ("10".to_string(), Status::New));
3372    }
3373
3374    #[tokio::test]
3375    async fn unblock_parents_skips_when_child_not_done() {
3376        let mut mock = MockBackend::new();
3377
3378        let parent = make_task("20", &["status:blocked"]);
3379        mock.blocked_tasks.push(parent.clone());
3380        mock.sub_issues.insert(
3381            "20".to_string(),
3382            vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3383        );
3384        // Child 21 is done, child 22 is still in_progress
3385        mock.tasks_by_id
3386            .insert("21".to_string(), make_task("21", &["status:done"]));
3387        mock.tasks_by_id
3388            .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3389
3390        let status_updates = Arc::clone(&mock.status_updates);
3391        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3392        let task_manager = make_task_manager(Arc::clone(&backend));
3393        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3394
3395        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3396            .await
3397            .unwrap();
3398
3399        let updates = status_updates.lock().unwrap();
3400        assert!(
3401            updates.is_empty(),
3402            "should not unblock when a child is still running"
3403        );
3404    }
3405
3406    #[tokio::test]
3407    async fn unblock_parents_skips_task_with_no_children() {
3408        let mut mock = MockBackend::new();
3409
3410        // Blocked task with no sub-issues (blocked for a different reason)
3411        let parent = make_task("30", &["status:blocked"]);
3412        mock.blocked_tasks.push(parent.clone());
3413        // No entry in sub_issues → get_sub_issues returns []
3414
3415        let status_updates = Arc::clone(&mock.status_updates);
3416        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3417        let task_manager = make_task_manager(Arc::clone(&backend));
3418        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3419
3420        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3421            .await
3422            .unwrap();
3423
3424        let updates = status_updates.lock().unwrap();
3425        assert!(
3426            updates.is_empty(),
3427            "should not unblock tasks with no sub-issues"
3428        );
3429    }
3430
3431    #[tokio::test]
3432    async fn unblock_parents_skips_task_with_block_reason_in_store() {
3433        // A blocked task that has a known block_reason in the store should never
3434        // trigger a get_sub_issues GraphQL call — it is not waiting on children.
3435        let mut mock = MockBackend::new();
3436
3437        // Blocked parent in the backend list
3438        let parent = make_task("60", &["status:blocked"]);
3439        mock.blocked_tasks.push(parent.clone());
3440        // Give it children in the backend so that, if the skip logic is wrong,
3441        // the parent would get unblocked.
3442        mock.sub_issues
3443            .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3444        mock.tasks_by_id
3445            .insert("61".to_string(), make_task("61", &["status:done"]));
3446
3447        let status_updates = Arc::clone(&mock.status_updates);
3448        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3449        let task_manager = make_task_manager(Arc::clone(&backend));
3450
3451        // Insert the blocked task into the store with a block_reason set.
3452        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3453        let task_id = store
3454            .upsert_external(&crate::store::UpsertExternal {
3455                repo: "test/repo",
3456                ext_id: "60",
3457                title: "Task 60",
3458                body: "",
3459                author: "bot",
3460                url: "https://github.com/test/test/issues/60",
3461                labels: &[],
3462                origin: "github",
3463            })
3464            .await
3465            .unwrap();
3466        store
3467            .update_status(task_id, TaskStatus::Blocked)
3468            .await
3469            .unwrap();
3470        store
3471            .set_block_reason(task_id, Some("CI failure limit reached"))
3472            .await
3473            .unwrap();
3474
3475        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3476            .await
3477            .unwrap();
3478
3479        let updates = status_updates.lock().unwrap();
3480        assert!(
3481            updates.is_empty(),
3482            "task with block_reason should be skipped — no API call, no unblock"
3483        );
3484    }
3485
3486    #[tokio::test]
3487    async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3488        let mock = MockBackend::new();
3489        let get_sub_issues_calls = Arc::clone(&mock.get_sub_issues_calls);
3490        let status_updates = Arc::clone(&mock.status_updates);
3491        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3492        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3493        let task_manager = Arc::new(TaskManager::with_store(
3494            Arc::clone(&backend),
3495            Arc::clone(&store),
3496            "test/repo".to_string(),
3497        ));
3498
3499        let parent_id = store
3500            .create_internal("test/repo", "Parent", "", "job", "daily", None)
3501            .await
3502            .unwrap();
3503        store
3504            .update_status(parent_id, TaskStatus::Blocked)
3505            .await
3506            .unwrap();
3507        let child_id = store
3508            .create(&NewTask {
3509                external_id: Some("91".to_string()),
3510                repo: "test/repo".to_string(),
3511                origin: "github".to_string(),
3512                title: "Child".to_string(),
3513                body: String::new(),
3514                source: String::new(),
3515                source_id: String::new(),
3516                author: String::new(),
3517                url: String::new(),
3518                labels: vec![],
3519                parent_id: Some(parent_id),
3520            })
3521            .await
3522            .unwrap();
3523        store
3524            .update_status(child_id, TaskStatus::Done)
3525            .await
3526            .unwrap();
3527
3528        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3529            .await
3530            .unwrap();
3531
3532        let parent = store.get(parent_id).await.unwrap();
3533        assert_eq!(parent.status, TaskStatus::New);
3534        assert!(
3535            status_updates.lock().unwrap().is_empty(),
3536            "internal parent should be updated in store without backend mirroring"
3537        );
3538        assert!(
3539            get_sub_issues_calls.lock().unwrap().is_empty(),
3540            "internal parent should not query backend sub-issues"
3541        );
3542    }
3543
3544    // ── tick_recover_stuck_tasks (InReview) ──────────────────────────────────
3545
3546    /// Set `updated_at` to a far-past date for a task in an in-memory store.
3547    #[cfg(test)]
3548    async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3549        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3550            .bind(task_id)
3551            .execute(store.pool())
3552            .await
3553            .unwrap();
3554    }
3555
3556    /// Age the in_review status-entry the same way the stale sweep tests do
3557    async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3558        sqlx::query(
3559            "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3560             WHERE task_id = ? AND to_status = 'in_review'",
3561        )
3562        .bind(task_id)
3563        .execute(store.pool())
3564        .await
3565        .unwrap();
3566    }
3567
3568    #[tokio::test]
3569    async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3570        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3571        let mock = MockBackend::new();
3572        let status_updates = Arc::clone(&mock.status_updates);
3573        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3574        let task_manager = Arc::new(TaskManager::with_store(
3575            Arc::clone(&backend),
3576            Arc::clone(&store),
3577            "owner/repo".to_string(),
3578        ));
3579        let tmux = Arc::new(TmuxManager::new());
3580        let config = EngineConfig {
3581            no_session_stuck_timeout: 600,
3582            stuck_timeout: 1800,
3583            ..EngineConfig::default()
3584        };
3585
3586        // Insert an external task and age both the status entry and updated_at
3587        let id = store
3588            .upsert_external(&crate::store::UpsertExternal {
3589                repo: "owner/repo",
3590                ext_id: "99",
3591                title: "Stuck InReview",
3592                body: "",
3593                author: "",
3594                url: "",
3595                labels: &[],
3596                origin: "github",
3597            })
3598            .await
3599            .unwrap();
3600        store.update_status(id, TaskStatus::InReview).await.unwrap();
3601        set_in_review_entered_at_past(&store, id).await;
3602        set_task_updated_at_past(&store, id).await;
3603
3604        tick_recover_stuck_tasks(
3605            &backend,
3606            &tmux,
3607            "owner/repo",
3608            &task_manager,
3609            &config,
3610            &store,
3611            &HashMap::new(),
3612            &Arc::new(DashMap::new()),
3613        )
3614        .await
3615        .unwrap();
3616
3617        let updates = status_updates.lock().unwrap();
3618        assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3619        assert_eq!(updates[0].0, "99");
3620        assert_eq!(updates[0].1, Status::NeedsReview);
3621    }
3622
3623    #[tokio::test]
3624    async fn recover_stuck_tasks_skips_recent_in_review() {
3625        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3626        let mock = MockBackend::new();
3627        let status_updates = Arc::clone(&mock.status_updates);
3628        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3629        let task_manager = Arc::new(TaskManager::with_store(
3630            Arc::clone(&backend),
3631            Arc::clone(&store),
3632            "owner/repo".to_string(),
3633        ));
3634        let tmux = Arc::new(TmuxManager::new());
3635        let config = EngineConfig {
3636            no_session_stuck_timeout: 600, // 10 minutes — recent task won't trigger
3637            stuck_timeout: 1800,
3638            ..EngineConfig::default()
3639        };
3640
3641        // Age updated_at, leave the in_review status entry at now
3642        let id = store
3643            .upsert_external(&crate::store::UpsertExternal {
3644                repo: "owner/repo",
3645                ext_id: "100",
3646                title: "Recent InReview",
3647                body: "",
3648                author: "",
3649                url: "",
3650                labels: &[],
3651                origin: "github",
3652            })
3653            .await
3654            .unwrap();
3655        store.update_status(id, TaskStatus::InReview).await.unwrap();
3656        set_task_updated_at_past(&store, id).await;
3657
3658        tick_recover_stuck_tasks(
3659            &backend,
3660            &tmux,
3661            "owner/repo",
3662            &task_manager,
3663            &config,
3664            &store,
3665            &HashMap::new(),
3666            &Arc::new(DashMap::new()),
3667        )
3668        .await
3669        .unwrap();
3670
3671        let updates = status_updates.lock().unwrap();
3672        assert!(
3673            updates.is_empty(),
3674            "recent InReview task should not be recovered"
3675        );
3676    }
3677
3678    #[tokio::test]
3679    async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3680        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3681        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3682        let task_manager = Arc::new(TaskManager::with_store(
3683            Arc::clone(&backend),
3684            Arc::clone(&store),
3685            "owner/repo".to_string(),
3686        ));
3687        let tmux = Arc::new(TmuxManager::new());
3688        let config = EngineConfig {
3689            no_session_stuck_timeout: 600,
3690            stuck_timeout: 1800,
3691            ..EngineConfig::default()
3692        };
3693
3694        // Create an internal task in InReview and age both clocks
3695        let id = store
3696            .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3697            .await
3698            .unwrap();
3699        store.update_status(id, TaskStatus::InReview).await.unwrap();
3700        set_in_review_entered_at_past(&store, id).await;
3701        set_task_updated_at_past(&store, id).await;
3702
3703        tick_recover_stuck_tasks(
3704            &backend,
3705            &tmux,
3706            "owner/repo",
3707            &task_manager,
3708            &config,
3709            &store,
3710            &HashMap::new(),
3711            &Arc::new(DashMap::new()),
3712        )
3713        .await
3714        .unwrap();
3715
3716        let task = store.get(id).await.unwrap();
3717        assert_eq!(
3718            task.status,
3719            TaskStatus::NeedsReview,
3720            "stuck internal InReview task should be reset to NeedsReview"
3721        );
3722    }
3723
3724    #[tokio::test]
3725    async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3726        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3727        let mock = MockBackend::new();
3728        let status_updates = Arc::clone(&mock.status_updates);
3729        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3730        let task_manager = Arc::new(TaskManager::with_store(
3731            Arc::clone(&backend),
3732            Arc::clone(&store),
3733            "owner/repo".to_string(),
3734        ));
3735        let tmux = Arc::new(TmuxManager::new());
3736        let config = EngineConfig {
3737            no_session_stuck_timeout: 600,
3738            stuck_timeout: 1800,
3739            ..EngineConfig::default()
3740        };
3741
3742        let id = store
3743            .upsert_external(&crate::store::UpsertExternal {
3744                repo: "owner/repo",
3745                ext_id: "102",
3746                title: "Fresh updated_at InReview",
3747                body: "",
3748                author: "",
3749                url: "",
3750                labels: &[],
3751                origin: "github",
3752            })
3753            .await
3754            .unwrap();
3755        store.update_status(id, TaskStatus::InReview).await.unwrap();
3756        set_in_review_entered_at_past(&store, id).await;
3757        store
3758            .set_fields(id, &[("pr_number", serde_json::json!(42))])
3759            .await
3760            .unwrap();
3761
3762        tick_recover_stuck_tasks(
3763            &backend,
3764            &tmux,
3765            "owner/repo",
3766            &task_manager,
3767            &config,
3768            &store,
3769            &HashMap::new(),
3770            &Arc::new(DashMap::new()),
3771        )
3772        .await
3773        .unwrap();
3774
3775        let updates = status_updates.lock().unwrap();
3776        assert_eq!(
3777            updates.len(),
3778            1,
3779            "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3780        );
3781        assert_eq!(updates[0].0, "102");
3782        assert_eq!(updates[0].1, Status::NeedsReview);
3783    }
3784
3785    /// An in_review task whose review session is gone goes back to needs_review, unless its
3786    /// review already recorded a decision and the task is only waiting on the merge
3787    async fn orphaned_review_status_after_recovery(with_decision: bool) -> TaskStatus {
3788        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3789        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3790        let task_manager = Arc::new(TaskManager::with_store(
3791            Arc::clone(&backend),
3792            Arc::clone(&store),
3793            "owner/repo".to_string(),
3794        ));
3795        let tmux = Arc::new(TmuxManager::new());
3796
3797        let id = store
3798            .upsert_external(&crate::store::UpsertExternal {
3799                repo: "owner/repo",
3800                ext_id: "55",
3801                title: "Orphaned review",
3802                body: "",
3803                author: "",
3804                url: "",
3805                labels: &[],
3806                origin: "github",
3807            })
3808            .await
3809            .unwrap();
3810        store.update_status(id, TaskStatus::InReview).await.unwrap();
3811        // Entered in_review long ago. updated_at stays fresh, as review_poll rewrites it
3812        set_in_review_entered_at_past(&store, id).await;
3813        if with_decision {
3814            store::store_log_activity(
3815                &Some(Arc::clone(&store)),
3816                "owner/repo",
3817                "55",
3818                "review_decision",
3819                None,
3820                None,
3821                None::<&str>,
3822                None::<&str>,
3823                Some(&serde_json::json!({"decision": "approve"})),
3824            )
3825            .await;
3826        }
3827
3828        tick_recover_stuck_tasks(
3829            &backend,
3830            &tmux,
3831            "owner/repo",
3832            &task_manager,
3833            &EngineConfig::default(),
3834            &store,
3835            &HashMap::new(),
3836            &Arc::new(DashMap::new()),
3837        )
3838        .await
3839        .unwrap();
3840
3841        store.get(id).await.unwrap().status
3842    }
3843
3844    #[tokio::test]
3845    async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3846        assert_eq!(
3847            orphaned_review_status_after_recovery(false).await,
3848            TaskStatus::NeedsReview
3849        );
3850    }
3851
3852    #[tokio::test]
3853    async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3854        assert_eq!(
3855            orphaned_review_status_after_recovery(true).await,
3856            TaskStatus::InReview
3857        );
3858    }
3859
3860    #[tokio::test]
3861    async fn recover_stuck_tasks_resets_review_agent_failures() {
3862        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3863        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3864        let task_manager = Arc::new(TaskManager::with_store(
3865            Arc::clone(&backend),
3866            Arc::clone(&store),
3867            "owner/repo".to_string(),
3868        ));
3869        let tmux = Arc::new(TmuxManager::new());
3870
3871        let id = store
3872            .upsert_external(&crate::store::UpsertExternal {
3873                repo: "owner/repo",
3874                ext_id: "104",
3875                title: "Orphaned review with failures",
3876                body: "",
3877                author: "",
3878                url: "",
3879                labels: &[],
3880                origin: "github",
3881            })
3882            .await
3883            .unwrap();
3884        store.update_status(id, TaskStatus::InReview).await.unwrap();
3885        store
3886            .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3887            .await
3888            .unwrap();
3889        set_in_review_entered_at_past(&store, id).await;
3890
3891        tick_recover_stuck_tasks(
3892            &backend,
3893            &tmux,
3894            "owner/repo",
3895            &task_manager,
3896            &EngineConfig::default(),
3897            &store,
3898            &HashMap::new(),
3899            &Arc::new(DashMap::new()),
3900        )
3901        .await
3902        .unwrap();
3903
3904        let task = store.get(id).await.unwrap();
3905        assert_eq!(task.status, TaskStatus::NeedsReview);
3906        assert_eq!(
3907            task.review_agent_failures, 0,
3908            "a dead review session is an infrastructure event, not an agent failure"
3909        );
3910    }
3911
3912    #[tokio::test]
3913    async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3914        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3915        let mock = MockBackend::new();
3916        let status_updates = Arc::clone(&mock.status_updates);
3917        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3918        let task_manager = Arc::new(TaskManager::with_store(
3919            Arc::clone(&backend),
3920            Arc::clone(&store),
3921            "owner/repo".to_string(),
3922        ));
3923        let tmux = Arc::new(TmuxManager::new());
3924        let config = EngineConfig {
3925            no_session_stuck_timeout: 600,
3926            stuck_timeout: 1800,
3927            ..EngineConfig::default()
3928        };
3929
3930        let id = store
3931            .upsert_external(&crate::store::UpsertExternal {
3932                repo: "owner/repo",
3933                ext_id: "103",
3934                title: "Hung review session",
3935                body: "",
3936                author: "",
3937                url: "",
3938                labels: &[],
3939                origin: "github",
3940            })
3941            .await
3942            .unwrap();
3943        store.update_status(id, TaskStatus::InReview).await.unwrap();
3944        set_in_review_entered_at_past(&store, id).await;
3945        store
3946            .set_fields(id, &[("pr_number", serde_json::json!(43))])
3947            .await
3948            .unwrap();
3949
3950        let session_name = tmux.session_name("owner/repo", "103-review");
3951        let mut session_map = HashMap::new();
3952        session_map.insert(session_name, true);
3953
3954        tick_recover_stuck_tasks(
3955            &backend,
3956            &tmux,
3957            "owner/repo",
3958            &task_manager,
3959            &config,
3960            &store,
3961            &session_map,
3962            &Arc::new(DashMap::new()),
3963        )
3964        .await
3965        .unwrap();
3966
3967        let updates = status_updates.lock().unwrap();
3968        assert_eq!(
3969            updates.len(),
3970            1,
3971            "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3972        );
3973        assert_eq!(updates[0].0, "103");
3974        assert_eq!(updates[0].1, Status::NeedsReview);
3975    }
3976
3977    #[tokio::test]
3978    async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3979        let store = Arc::new(TaskStore::open_memory().await.unwrap());
3980        let tmux = Arc::new(TmuxManager::new());
3981        let config = EngineConfig {
3982            no_session_stuck_timeout: 600,
3983            stuck_timeout: 1800,
3984            ..EngineConfig::default()
3985        };
3986
3987        // Cross-repo task should still be recovered even when current tick repo is owner/repo.
3988        let id = store
3989            .upsert_external(&crate::store::UpsertExternal {
3990                repo: "owner/other",
3991                ext_id: "777",
3992                title: "Cross-repo stuck task",
3993                body: "",
3994                author: "",
3995                url: "",
3996                labels: &[],
3997                origin: "github",
3998            })
3999            .await
4000            .unwrap();
4001        store
4002            .update_status(id, TaskStatus::InProgress)
4003            .await
4004            .unwrap();
4005        set_task_updated_at_past(&store, id).await;
4006
4007        let active = HashSet::from(["owner/repo".to_string()]);
4008        recover_untracked_repo_stuck_tasks(
4009            &tmux,
4010            &config,
4011            &store,
4012            &HashMap::new(),
4013            &Arc::new(DashMap::new()),
4014            &active,
4015        )
4016        .await;
4017
4018        let task = store.get(id).await.unwrap();
4019        assert_eq!(
4020            task.status,
4021            TaskStatus::New,
4022            "cross-repo stale in_progress task should be reset to New"
4023        );
4024        assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
4025        assert!(task.agent.is_none(), "agent should be cleared");
4026        assert!(task.model.is_none(), "model should be cleared");
4027    }
4028
4029    #[tokio::test]
4030    async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
4031        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4032        let tmux = Arc::new(TmuxManager::new());
4033        let config = EngineConfig {
4034            no_session_stuck_timeout: 600,
4035            stuck_timeout: 1800,
4036            ..EngineConfig::default()
4037        };
4038        let id = store
4039            .upsert_external(&crate::store::UpsertExternal {
4040                repo: "owner/other",
4041                ext_id: "778",
4042                title: "Stuck task in an active repo",
4043                body: "",
4044                author: "",
4045                url: "",
4046                labels: &[],
4047                origin: "github",
4048            })
4049            .await
4050            .unwrap();
4051        store
4052            .update_status(id, TaskStatus::InProgress)
4053            .await
4054            .unwrap();
4055        set_task_updated_at_past(&store, id).await;
4056
4057        // Per-project passes run concurrently, so only the task's own pass may reclaim it
4058        let active = HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
4059        recover_untracked_repo_stuck_tasks(
4060            &tmux,
4061            &config,
4062            &store,
4063            &HashMap::new(),
4064            &Arc::new(DashMap::new()),
4065            &active,
4066        )
4067        .await;
4068
4069        assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
4070    }
4071
4072    #[tokio::test]
4073    async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
4074        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4075        let mock = MockBackend::new();
4076        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4077        let task_manager = Arc::new(TaskManager::with_store(
4078            Arc::clone(&backend),
4079            Arc::clone(&store),
4080            "owner/repo".to_string(),
4081        ));
4082        let tmux = Arc::new(TmuxManager::new());
4083        let config = EngineConfig {
4084            no_session_stuck_timeout: 600,
4085            stuck_timeout: 1800,
4086            ..EngineConfig::default()
4087        };
4088
4089        let id = store
4090            .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
4091            .await
4092            .unwrap();
4093        store
4094            .set_fields(
4095                id,
4096                &[
4097                    ("agent", serde_json::json!("claude")),
4098                    ("model", serde_json::json!("sonnet")),
4099                    ("route_attempts", serde_json::json!(1)),
4100                ],
4101            )
4102            .await
4103            .unwrap();
4104        store
4105            .update_status(id, TaskStatus::InProgress)
4106            .await
4107            .unwrap();
4108        set_task_updated_at_past(&store, id).await;
4109
4110        // Start an agent run that will be orphaned by recovery
4111        store
4112            .start_run(&crate::store::StartRun {
4113                task_id: id,
4114                attempt: 1,
4115                run_type: "agent",
4116                agent: "claude",
4117                model: "sonnet",
4118                command: "claude --model sonnet",
4119                prompt: "fix it",
4120            })
4121            .await
4122            .unwrap();
4123
4124        tick_recover_stuck_tasks(
4125            &backend,
4126            &tmux,
4127            "owner/repo",
4128            &task_manager,
4129            &config,
4130            &store,
4131            &HashMap::new(),
4132            &Arc::new(DashMap::new()),
4133        )
4134        .await
4135        .unwrap();
4136
4137        let task = store.get(id).await.unwrap();
4138        assert_eq!(task.status, TaskStatus::New);
4139
4140        let runs = store.get_runs(id).await.unwrap();
4141        assert_eq!(runs.len(), 1);
4142        assert_eq!(runs[0].outcome, "aborted");
4143        assert!(runs[0].completed_at.is_some());
4144        assert_eq!(
4145            runs[0].error,
4146            "stuck-task recovery: internal no session found"
4147        );
4148    }
4149
4150    /// Regression test for #3518: a completed agent run must not be discarded by the
4151    /// stuck-task reclaim just because its tmux session has already exited.
4152    ///
4153    /// In production, sessions are created without `remain-on-exit`, so once the agent
4154    /// process exits tmux removes the session immediately — there is no observable
4155    /// "dead pane" state for `tick_check_session_completions` to catch, and the runner's
4156    /// own `store_touch_updated_at` only fires after its own poll notices the exit (up to
4157    /// its 5s poll interval) plus post-processing. For a task whose total runtime already
4158    /// exceeds `no_session_stuck_timeout`, the very next tick that observes the (now
4159    /// absent) session would otherwise reclaim it instantly — discarding a successful run.
4160    ///
4161    /// The fix: `tick_recover_stuck_tasks` skips the no-session reclaim while the task's
4162    /// dispatch guard (the in-memory `dispatching` map keyed by "{repo}/{task_id}",
4163    /// populated for the entire duration of `runner.run_with_context`) is still held —
4164    /// i.e. while this process is still actively running the task, regardless of tmux
4165    /// session state.
4166    #[tokio::test]
4167    async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4168        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4169        let mock = MockBackend::new();
4170        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4171        let task_manager = Arc::new(TaskManager::with_store(
4172            Arc::clone(&backend),
4173            Arc::clone(&store),
4174            "owner/repo".to_string(),
4175        ));
4176        let tmux = Arc::new(TmuxManager::new());
4177        let config = EngineConfig {
4178            no_session_stuck_timeout: 600,
4179            stuck_timeout: 1800,
4180            ..EngineConfig::default()
4181        };
4182
4183        let id = store
4184            .create_internal(
4185                "owner/repo",
4186                "Long-running daily job",
4187                "",
4188                "cron",
4189                "1",
4190                None,
4191            )
4192            .await
4193            .unwrap();
4194        store
4195            .set_fields(
4196                id,
4197                &[
4198                    ("agent", serde_json::json!("claude")),
4199                    ("model", serde_json::json!("sonnet")),
4200                    ("route_attempts", serde_json::json!(1)),
4201                ],
4202            )
4203            .await
4204            .unwrap();
4205        store
4206            .update_status(id, TaskStatus::InProgress)
4207            .await
4208            .unwrap();
4209        // Simulate a task that has already run well past no_session_stuck_timeout,
4210        // as a legitimately long agent run would.
4211        set_task_updated_at_past(&store, id).await;
4212
4213        // Simulate the runner still actively processing this task: its dispatch guard
4214        // is held even though the tmux session (empty session_map below) is already gone.
4215        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4216        dispatching.insert(
4217            format!("owner/repo/internal:{id}"),
4218            format!("internal:{id}"),
4219        );
4220
4221        tick_recover_stuck_tasks(
4222            &backend,
4223            &tmux,
4224            "owner/repo",
4225            &task_manager,
4226            &config,
4227            &store,
4228            &HashMap::new(),
4229            &dispatching,
4230        )
4231        .await
4232        .unwrap();
4233
4234        let task = store.get(id).await.unwrap();
4235        assert_eq!(
4236            task.status,
4237            TaskStatus::InProgress,
4238            "task should not be reclaimed while its dispatch guard is still held"
4239        );
4240
4241        // No run should have been finalized/aborted either.
4242        let runs = store.get_runs(id).await.unwrap();
4243        assert!(runs.is_empty());
4244
4245        // Once the guard is released (runner finished), the very same conditions should
4246        // reclaim the task as before — the fix must not disable the safety net entirely.
4247        dispatching.clear();
4248        tick_recover_stuck_tasks(
4249            &backend,
4250            &tmux,
4251            "owner/repo",
4252            &task_manager,
4253            &config,
4254            &store,
4255            &HashMap::new(),
4256            &dispatching,
4257        )
4258        .await
4259        .unwrap();
4260        let task = store.get(id).await.unwrap();
4261        assert_eq!(
4262            task.status,
4263            TaskStatus::New,
4264            "task should still be reclaimed once the dispatch guard is released"
4265        );
4266    }
4267
4268    /// Concurrency regression test for issue #3526: the reclaim race recurred twice
4269    /// (internal:156563 in #3523, internal:156673 in #3526) *after* the #3519/#3525
4270    /// fix landed, in production under the real multi-threaded tokio runtime. The
4271    /// existing `recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held` test
4272    /// above only exercises sequential `.await` calls on a single-threaded test
4273    /// runtime (`#[tokio::test]` defaults to `flavor = "current_thread"`), which can
4274    /// never observe genuine cross-thread races in the shared `dispatching` DashMap.
4275    ///
4276    /// This test claims the guard exactly the way `dispatch_tasks_for_repo` does
4277    /// (atomic `DashMap::entry` claim, `DispatchGuard` RAII release), hands it to a
4278    /// spawned task running on a *different* worker thread that holds it until
4279    /// explicitly told to release, and hammers the real `tick_recover_stuck_tasks`
4280    /// from the main test task hundreds of times while the guard is deterministically
4281    /// still held. If there were a visibility gap between `DashMap::entry().insert()`
4282    /// on one thread and `DashMap::contains_key()` on another, this test would catch it.
4283    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4284    async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4285        const HAMMER_ITERATIONS: u32 = 300;
4286
4287        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4288        let mock = MockBackend::new();
4289        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4290        let task_manager = Arc::new(TaskManager::with_store(
4291            Arc::clone(&backend),
4292            Arc::clone(&store),
4293            "owner/repo".to_string(),
4294        ));
4295        let tmux = Arc::new(TmuxManager::new());
4296        let config = EngineConfig {
4297            no_session_stuck_timeout: 600,
4298            stuck_timeout: 1800,
4299            ..EngineConfig::default()
4300        };
4301
4302        let id = store
4303            .create_internal(
4304                "owner/repo",
4305                "Long-running daily job",
4306                "",
4307                "cron",
4308                "1",
4309                None,
4310            )
4311            .await
4312            .unwrap();
4313        store
4314            .set_fields(
4315                id,
4316                &[
4317                    ("agent", serde_json::json!("claude")),
4318                    ("model", serde_json::json!("sonnet")),
4319                    ("route_attempts", serde_json::json!(1)),
4320                ],
4321            )
4322            .await
4323            .unwrap();
4324        store
4325            .update_status(id, TaskStatus::InProgress)
4326            .await
4327            .unwrap();
4328        set_task_updated_at_past(&store, id).await;
4329
4330        let dispatch_key = format!("owner/repo/internal:{id}");
4331        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4332
4333        // Claim the guard synchronously, on this task, exactly like
4334        // dispatch_tasks_for_repo does: the atomic entry-claim always happens before
4335        // tokio::spawn hands the guard to the background task. (Claiming inside the
4336        // spawned task itself would race the hammer loop below against tokio's own
4337        // scheduling of that task onto a worker thread — a test artifact, not the
4338        // real production ordering, which guarantees claim-before-spawn.)
4339        let claim_task_id = format!("internal:{id}");
4340        {
4341            match dispatching.entry(dispatch_key.clone()) {
4342                Entry::Vacant(slot) => {
4343                    slot.insert(claim_task_id);
4344                }
4345                Entry::Occupied(_) => panic!("dispatch key already claimed"),
4346            }
4347        }
4348        let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4349
4350        // Hold the guard on a spawned task — a different worker thread under the
4351        // multi-thread runtime — mirroring the real runner holding it across genuine
4352        // async work (worktree setup, agent session, response handling) inside
4353        // `tokio::spawn(... run_with_context ...)`. It only drops the guard once
4354        // explicitly told to via `release_rx`, so its lifetime is fully controlled
4355        // by this test rather than raced against a timer — the release-signal
4356        // ordering was itself a source of false positives in an earlier version of
4357        // this test (a `sleep`-then-drop holder can legitimately release the guard
4358        // in the gap between the hammer loop's last check and its next iteration,
4359        // which is a correct outcome, not a bug, but made the assertion flaky).
4360        let (release_tx, release_rx) = oneshot::channel::<()>();
4361        let holder = tokio::spawn(async move {
4362            let _ = release_rx.await;
4363            drop(guard);
4364        });
4365
4366        // Hammer the real reclaim path concurrently from the main task while the
4367        // guard is deterministically still held (the holder task is parked on
4368        // `release_rx` and cannot drop the guard until we signal it below). Each
4369        // iteration's `tick_recover_stuck_tasks` call may itself run on any worker
4370        // thread the tokio scheduler picks, independent of the thread that claimed
4371        // the guard — this is what actually exercises cross-thread DashMap
4372        // visibility, not wall-clock racing against a timer.
4373        for i in 0..HAMMER_ITERATIONS {
4374            tick_recover_stuck_tasks(
4375                &backend,
4376                &tmux,
4377                "owner/repo",
4378                &task_manager,
4379                &config,
4380                &store,
4381                &HashMap::new(),
4382                &dispatching,
4383            )
4384            .await
4385            .unwrap();
4386
4387            let task = store.get(id).await.unwrap();
4388            assert_eq!(
4389                task.status,
4390                TaskStatus::InProgress,
4391                "task must never be reclaimed while the dispatch guard is deterministically \
4392                 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4393                 iteration={i}, contains_key_now={}, map_len_now={})",
4394                dispatching.contains_key(&dispatch_key),
4395                dispatching.len(),
4396            );
4397            task::yield_now().await;
4398        }
4399
4400        // Now release the guard and confirm the holder task actually completed.
4401        let _ = release_tx.send(());
4402        holder.await.unwrap();
4403
4404        // Guard released — the safety net must still reclaim as before.
4405        tick_recover_stuck_tasks(
4406            &backend,
4407            &tmux,
4408            "owner/repo",
4409            &task_manager,
4410            &config,
4411            &store,
4412            &HashMap::new(),
4413            &dispatching,
4414        )
4415        .await
4416        .unwrap();
4417        let task = store.get(id).await.unwrap();
4418        assert_eq!(
4419            task.status,
4420            TaskStatus::New,
4421            "task should be reclaimed once the dispatch guard is released"
4422        );
4423    }
4424
4425    #[tokio::test]
4426    async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4427        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4428        let mock = MockBackend::new();
4429        let status_updates = Arc::clone(&mock.status_updates);
4430        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4431        let task_manager = Arc::new(TaskManager::with_store(
4432            Arc::clone(&backend),
4433            Arc::clone(&store),
4434            "owner/repo".to_string(),
4435        ));
4436        let tmux = Arc::new(TmuxManager::new());
4437        let config = EngineConfig {
4438            no_session_stuck_timeout: 600,
4439            stuck_timeout: 1800,
4440            ..EngineConfig::default()
4441        };
4442
4443        let id = store
4444            .upsert_external(&crate::store::UpsertExternal {
4445                repo: "owner/repo",
4446                ext_id: "101",
4447                title: "Waiting For Human Review",
4448                body: "",
4449                author: "",
4450                url: "",
4451                labels: &[],
4452                origin: "github",
4453            })
4454            .await
4455            .unwrap();
4456        store.update_status(id, TaskStatus::InReview).await.unwrap();
4457        set_task_updated_at_past(&store, id).await;
4458
4459        tick_recover_stuck_tasks(
4460            &backend,
4461            &tmux,
4462            "owner/repo",
4463            &task_manager,
4464            &config,
4465            &store,
4466            &HashMap::new(),
4467            &Arc::new(DashMap::new()),
4468        )
4469        .await
4470        .unwrap();
4471
4472        let updates = status_updates.lock().unwrap();
4473        assert!(
4474            updates.is_empty(),
4475            "InReview task waiting for human review should not be reset"
4476        );
4477    }
4478
4479    #[tokio::test]
4480    async fn unblock_parents_no_blocked_tasks() {
4481        let mock = MockBackend::new(); // no blocked tasks
4482        let status_updates = Arc::clone(&mock.status_updates);
4483        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4484        let task_manager = make_task_manager(Arc::clone(&backend));
4485        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4486
4487        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4488            .await
4489            .unwrap();
4490
4491        assert!(status_updates.lock().unwrap().is_empty());
4492    }
4493
4494    #[tokio::test]
4495    async fn unblock_parents_handles_multiple_blocked_tasks() {
4496        let mut mock = MockBackend::new();
4497
4498        // Task 40: all children done → should unblock
4499        let t40 = make_task("40", &["status:blocked"]);
4500        mock.blocked_tasks.push(t40);
4501        mock.sub_issues
4502            .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4503        mock.tasks_by_id
4504            .insert("41".to_string(), make_task("41", &["status:done"]));
4505
4506        // Task 50: child not done → should stay blocked
4507        let t50 = make_task("50", &["status:blocked"]);
4508        mock.blocked_tasks.push(t50);
4509        mock.sub_issues
4510            .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4511        mock.tasks_by_id
4512            .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4513
4514        let status_updates = Arc::clone(&mock.status_updates);
4515        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4516        let task_manager = make_task_manager(Arc::clone(&backend));
4517        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4518
4519        tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4520            .await
4521            .unwrap();
4522
4523        let updates = status_updates.lock().unwrap();
4524        assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4525        assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4526        assert_eq!(updates[0].1, Status::New);
4527    }
4528
4529    /// `tick_all` reads leadership and the global sweeps from the first project's store, so
4530    /// an empty project list must return before any of that, and touch nothing.
4531    #[tokio::test]
4532    async fn tick_all_is_a_no_op_without_projects() {
4533        let transport = Arc::new(Transport::new());
4534        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4535        let tmux = Arc::new(TmuxManager::new());
4536        let semaphore = Arc::new(Semaphore::new(1));
4537        let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4538        let (weight_tx, mut weight_rx) = mpsc::channel(8);
4539        let dispatching = Arc::new(DashMap::new());
4540        let auto_merge_in_flight = Arc::new(DashSet::new());
4541
4542        tick_all(
4543            &[],
4544            &tmux,
4545            &capture,
4546            &semaphore,
4547            &EngineConfig::default(),
4548            &router,
4549            &weight_tx,
4550            &dispatching,
4551            Some(&transport),
4552            &HashSet::new(),
4553            &auto_merge_in_flight,
4554        )
4555        .await;
4556
4557        assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4558        assert!(
4559            weight_rx.try_recv().is_err(),
4560            "no project, so no weight signal"
4561        );
4562    }
4563
4564    /// The store-wide sweeps must run once per tick, not once per project. The NeedsReview
4565    /// refire sweep reads `needs_review_refires` and then increments it, so when it ran
4566    /// inside the per-project join every project saw the same count and spent one of the
4567    /// five refire attempts — four projects burned four attempts in a single tick (#3864).
4568    #[serial_test::serial(cooldown_state)]
4569    #[tokio::test]
4570    async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4571        cooldown::reset_global_state().await;
4572
4573        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4574        let id = store
4575            .upsert_external(&UpsertExternal {
4576                repo: "owner/one",
4577                ext_id: "7",
4578                title: "Stale needs_review task",
4579                body: "",
4580                author: "user",
4581                url: "https://github.com/owner/one/issues/7",
4582                labels: &[],
4583                origin: "github",
4584            })
4585            .await
4586            .unwrap();
4587        store
4588            .update_status(id, TaskStatus::NeedsReview)
4589            .await
4590            .unwrap();
4591        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4592            .bind(id)
4593            .execute(store.pool())
4594            .await
4595            .unwrap();
4596
4597        let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4598            .iter()
4599            .map(|repo| {
4600                let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4601                ProjectEngine {
4602                    repo: repo.to_string(),
4603                    project_dir: PathBuf::from("/nonexistent"),
4604                    backend: Arc::clone(&backend),
4605                    task_manager: Arc::new(TaskManager::with_store(
4606                        backend,
4607                        Arc::clone(&store),
4608                        repo.to_string(),
4609                    )),
4610                    runner: Arc::new(TaskRunner::new(repo.to_string())),
4611                    store: Arc::clone(&store),
4612                }
4613            })
4614            .collect();
4615
4616        // A healthy review agent, so the sweep does not skip the pass
4617        let mut config = RouterConfig::default();
4618        config
4619            .model_map
4620            .entry("review".to_string())
4621            .or_default()
4622            .insert("claude".to_string(), vec!["sonnet".to_string()]);
4623        let router = Arc::new(RwLock::new(Router::new_for_test(
4624            config,
4625            vec!["claude".to_string()],
4626        )));
4627
4628        let transport = Arc::new(Transport::new());
4629        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4630        let (weight_tx, _weight_rx) = mpsc::channel(8);
4631
4632        tick_all(
4633            &engines,
4634            &Arc::new(TmuxManager::new()),
4635            &capture,
4636            &Arc::new(Semaphore::new(1)),
4637            &EngineConfig::default(),
4638            &router,
4639            &weight_tx,
4640            &Arc::new(DashMap::new()),
4641            Some(&transport),
4642            &engines.iter().map(|e| e.repo.clone()).collect(),
4643            &Arc::new(DashSet::new()),
4644        )
4645        .await;
4646
4647        assert_eq!(
4648            store.get(id).await.unwrap().needs_review_refires,
4649            1,
4650            "two projects must share one pass of the global NeedsReview sweep"
4651        );
4652    }
4653
4654    /// `tick_all` must not reap tmux sessions it did not start. Startup cleanup used to
4655    /// live here, and nextest's per-test process made the once-flag kill live agents (#3889)
4656    #[tokio::test]
4657    async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4658        let tmux = Arc::new(TmuxManager::new());
4659        let Ok(session_name) = tmux
4660            .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4661            .await
4662        else {
4663            eprintln!("Skipping test: tmux not available or failed to create test session");
4664            return;
4665        };
4666        let _guard = SessionGuard(session_name.clone());
4667
4668        // Cross a unix-second boundary so a reintroduced startup_cleanup would treat
4669        // this session as stale (created_at < now)
4670        time::sleep(Duration::from_millis(1100)).await;
4671
4672        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4673        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4674        let engines = vec![ProjectEngine {
4675            repo: "owner/one".to_string(),
4676            project_dir: PathBuf::from("/nonexistent"),
4677            backend: Arc::clone(&backend),
4678            task_manager: Arc::new(TaskManager::with_store(
4679                backend,
4680                Arc::clone(&store),
4681                "owner/one".to_string(),
4682            )),
4683            runner: Arc::new(TaskRunner::new("owner/one".to_string())),
4684            store,
4685        }];
4686
4687        let transport = Arc::new(Transport::new());
4688        let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4689        let (weight_tx, _weight_rx) = mpsc::channel(8);
4690        let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4691
4692        tick_all(
4693            &engines,
4694            &tmux,
4695            &capture,
4696            &Arc::new(Semaphore::new(1)),
4697            &EngineConfig::default(),
4698            &router,
4699            &weight_tx,
4700            &Arc::new(DashMap::new()),
4701            Some(&transport),
4702            &engines.iter().map(|e| e.repo.clone()).collect(),
4703            &Arc::new(DashSet::new()),
4704        )
4705        .await;
4706
4707        assert!(
4708            tmux.session_exists(&session_name).await,
4709            "tick_all must not kill a tmux session it did not start"
4710        );
4711    }
4712
4713    #[tokio::test]
4714    async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4715        let transport = Arc::new(Transport::new());
4716        let capture = Arc::new(CaptureService::new(transport));
4717        let mock = Arc::new(MockBackend::new());
4718        let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4719        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4720        let task_manager = Arc::new(TaskManager::with_store(
4721            Arc::clone(&backend),
4722            Arc::clone(&store),
4723            "owner/repo".to_string(),
4724        ));
4725        let tmux = Arc::new(TmuxManager::new());
4726        let config = EngineConfig {
4727            silence_grace_period: 0,
4728            ..EngineConfig::default()
4729        };
4730
4731        let internal_id = store
4732            .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4733            .await
4734            .unwrap();
4735        store
4736            .update_status(internal_id, TaskStatus::InProgress)
4737            .await
4738            .unwrap();
4739
4740        let task_id = format!("internal:{internal_id}");
4741        capture
4742            .register_session("owner/repo", &task_id, "orch-test-internal")
4743            .await;
4744        time::sleep(Duration::from_millis(1100)).await;
4745
4746        tick_detect_silent_agents(
4747            &tmux,
4748            "owner/repo",
4749            &capture,
4750            &backend,
4751            &task_manager,
4752            &config,
4753            &store,
4754        )
4755        .await
4756        .unwrap();
4757
4758        assert!(
4759            mock.removed_labels.lock().unwrap().is_empty(),
4760            "internal tasks should skip backend label removal"
4761        );
4762        assert!(
4763            mock.posted_comments.lock().unwrap().is_empty(),
4764            "internal tasks should skip backend comments"
4765        );
4766
4767        let task = store.get(internal_id).await.unwrap();
4768        // Silence detection now uses failover: if a fallback agent is found → Routed,
4769        // otherwise → New (reset for re-routing when cooldowns expire). Either is
4770        // acceptable; the key invariant is that the task is no longer InProgress.
4771        assert!(
4772            matches!(task.status, TaskStatus::Routed | TaskStatus::New),
4773            "internal task should be routed to fallback or reset to new, got {:?}",
4774            task.status
4775        );
4776    }
4777
4778    #[tokio::test]
4779    async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4780        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4781        let mock = MockBackend::new();
4782        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4783        let task_manager = Arc::new(TaskManager::with_store(
4784            Arc::clone(&backend),
4785            Arc::clone(&store),
4786            "owner/repo".to_string(),
4787        ));
4788        let transport = Arc::new(Transport::new());
4789        let capture = Arc::new(CaptureService::new(transport));
4790        let tmux = Arc::new(TmuxManager::new());
4791        let config = EngineConfig {
4792            silence_grace_period: 60,
4793            silence_cooldown: 300,
4794            ..EngineConfig::default()
4795        };
4796
4797        let internal_id = store
4798            .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4799            .await
4800            .unwrap();
4801        store
4802            .set_fields(
4803                internal_id,
4804                &[
4805                    ("agent", serde_json::json!("claude")),
4806                    ("model", serde_json::json!("sonnet")),
4807                ],
4808            )
4809            .await
4810            .unwrap();
4811        store
4812            .update_status(internal_id, TaskStatus::InProgress)
4813            .await
4814            .unwrap();
4815
4816        let task_id = format!("internal:{internal_id}");
4817        capture
4818            .register_session("owner/repo", &task_id, "orch-test-silent-run")
4819            .await;
4820        capture
4821            .set_buffer_state_for_test(
4822                "owner/repo",
4823                &task_id,
4824                false,
4825                false,
4826                chrono::Utc::now() - chrono::Duration::seconds(120),
4827            )
4828            .await;
4829
4830        // Start an agent run that will be orphaned by silence detection
4831        store
4832            .start_run(&crate::store::StartRun {
4833                task_id: internal_id,
4834                attempt: 1,
4835                run_type: "agent",
4836                agent: "claude",
4837                model: "sonnet",
4838                command: "claude --model sonnet",
4839                prompt: "fix it",
4840            })
4841            .await
4842            .unwrap();
4843
4844        tick_detect_silent_agents(
4845            &tmux,
4846            "owner/repo",
4847            &capture,
4848            &backend,
4849            &task_manager,
4850            &config,
4851            &store,
4852        )
4853        .await
4854        .unwrap();
4855
4856        let runs = store.get_runs(internal_id).await.unwrap();
4857        assert_eq!(runs.len(), 1);
4858        assert_eq!(runs[0].outcome, "aborted");
4859        assert!(runs[0].completed_at.is_some());
4860        assert_eq!(runs[0].error, "silence detection: session killed");
4861    }
4862
4863    /// Regression test for the race between Phase 1 (tick_check_session_completions)
4864    /// and Phase 2 (tick_recover_stuck_tasks).
4865    ///
4866    /// Before the fix, Phase 1 killed the tmux session without touching updated_at.
4867    /// Phase 2 could then see status=in_progress, has_session=false, and an old
4868    /// updated_at, and would reset the task to New — even though the runner's
4869    /// wait_for_completion() poll had not yet returned.
4870    ///
4871    /// The fix: Phase 1 touches updated_at as soon as it detects the session is done.
4872    /// This test verifies that after the touch, Phase 2 does NOT reclaim the task.
4873    #[tokio::test]
4874    async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4875        let store = Arc::new(TaskStore::open_memory().await.unwrap());
4876        let mock = MockBackend::new();
4877        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4878        let task_manager = Arc::new(TaskManager::with_store(
4879            Arc::clone(&backend),
4880            Arc::clone(&store),
4881            "owner/repo".to_string(),
4882        ));
4883        let tmux = Arc::new(TmuxManager::new());
4884        let config = EngineConfig {
4885            no_session_stuck_timeout: 600,
4886            stuck_timeout: 1800,
4887            ..EngineConfig::default()
4888        };
4889
4890        // Create an in-progress internal task with old updated_at.
4891        // Without the fix this task would be reclaimed by Phase 2 (age >> threshold,
4892        // no session running in the test environment).
4893        let id = store
4894            .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4895            .await
4896            .unwrap();
4897        store
4898            .update_status(id, TaskStatus::InProgress)
4899            .await
4900            .unwrap();
4901        set_task_updated_at_past(&store, id).await;
4902
4903        // Simulate Phase 1: touch updated_at when the session is detected as done.
4904        // In production this happens inside tick_check_session_completions.
4905        let task_id = format!("internal:{id}");
4906        store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4907
4908        // Phase 2 runs — updated_at is fresh so the task must NOT be reclaimed.
4909        tick_recover_stuck_tasks(
4910            &backend,
4911            &tmux,
4912            "owner/repo",
4913            &task_manager,
4914            &config,
4915            &store,
4916            &HashMap::new(),
4917            &Arc::new(DashMap::new()),
4918        )
4919        .await
4920        .unwrap();
4921
4922        let task = store.get(id).await.unwrap();
4923        assert_eq!(
4924            task.status,
4925            TaskStatus::InProgress,
4926            "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4927        );
4928    }
4929
4930    #[tokio::test]
4931    async fn stuck_task_timing_treats_dead_session_as_missing() {
4932        let tmux = Arc::new(TmuxManager::new());
4933        let repo = "owner/repo";
4934        let task_id = "internal:33498";
4935        let session_name = tmux.session_name(repo, task_id);
4936
4937        let create_result = tmux
4938            .command()
4939            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4940            .output()
4941            .await;
4942
4943        match create_result {
4944            Ok(o) if o.status.success() => {}
4945            _ => {
4946                eprintln!("Skipping test: tmux not available or failed to create test session");
4947                return;
4948            }
4949        }
4950
4951        let _guard = SessionGuard(session_name.clone());
4952
4953        let set_option_result = tmux
4954            .command()
4955            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4956            .output()
4957            .await;
4958        if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4959            eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4960            return;
4961        }
4962
4963        let send_exit_result = tmux
4964            .command()
4965            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4966            .output()
4967            .await;
4968        if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4969            eprintln!("Skipping test: unable to exit tmux pane");
4970            return;
4971        }
4972
4973        // Poll until pane is dead (up to 2s) — fixed sleep is unreliable under load.
4974        for _ in 0..20 {
4975            time::sleep(Duration::from_millis(100)).await;
4976            if !tmux.session_is_running(&session_name).await {
4977                break;
4978            }
4979        }
4980
4981        // Also wait for batch_session_active to reflect the dead state.
4982        // session_is_running uses `list-panes -t <session>` which may settle
4983        // before the global `list-panes -a` snapshot used by the production code.
4984        // On CI under load these two tmux commands can transiently disagree.
4985        let mut session_map = tmux.batch_session_active().await;
4986        for _ in 0..20 {
4987            if !session_map.get(&session_name).copied().unwrap_or(false) {
4988                break;
4989            }
4990            time::sleep(Duration::from_millis(100)).await;
4991            session_map = tmux.batch_session_active().await;
4992        }
4993        if session_map.get(&session_name).copied().unwrap_or(false) {
4994            eprintln!(
4995                "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
4996            );
4997            return;
4998        }
4999
5000        let config = EngineConfig {
5001            no_session_stuck_timeout: 600,
5002            stuck_timeout: 1800,
5003            ..EngineConfig::default()
5004        };
5005        let updated_at = (chrono::Utc::now()
5006            - chrono::Duration::seconds(
5007                i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
5008            ))
5009        .to_rfc3339();
5010
5011        let timing = stuck_task_timing_from_map(
5012            &tmux,
5013            repo,
5014            task_id,
5015            &updated_at,
5016            &config,
5017            "parse failure",
5018            &session_map,
5019        )
5020        .expect("dead session should be treated as missing");
5021
5022        assert!(!timing.has_session);
5023        assert_eq!(timing.threshold, config.no_session_stuck_timeout);
5024    }
5025
5026    #[tokio::test]
5027    #[serial_test::serial(suspend_state)]
5028    async fn stuck_task_timing_discounts_host_suspend_gap() {
5029        super::super::suspend::clear_for_test();
5030
5031        let tmux = Arc::new(TmuxManager::new());
5032        let repo = "owner/repo";
5033        let task_id = "internal:99001";
5034        let session_map = HashMap::new();
5035
5036        let config = EngineConfig {
5037            no_session_stuck_timeout: 1800,
5038            stuck_timeout: 1800,
5039            ..EngineConfig::default()
5040        };
5041
5042        // Task was last touched 33 minutes ago (age_mins=33 matches the reported
5043        // false-positive), but the host was suspended for 30 of those minutes —
5044        // real elapsed runtime is only 3 minutes, well under the 30-minute threshold.
5045        let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
5046        let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
5047        super::super::suspend::inject_gap_for_test(
5048            suspend_gap_detected_at,
5049            chrono::Duration::minutes(30),
5050        );
5051
5052        let timing = stuck_task_timing_from_map(
5053            &tmux,
5054            repo,
5055            task_id,
5056            &updated_at,
5057            &config,
5058            "parse failure",
5059            &session_map,
5060        );
5061
5062        assert!(
5063            timing.is_none(),
5064            "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
5065        );
5066
5067        super::super::suspend::clear_for_test();
5068    }
5069
5070    #[tokio::test]
5071    async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
5072        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5073        let mock = MockBackend::new();
5074        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5075        let task_manager = Arc::new(TaskManager::with_store(
5076            Arc::clone(&backend),
5077            Arc::clone(&store),
5078            "owner/repo".to_string(),
5079        ));
5080        let tmux = Arc::new(TmuxManager::new());
5081        let config = EngineConfig {
5082            no_session_stuck_timeout: 600,
5083            stuck_timeout: 1,
5084            ..EngineConfig::default()
5085        };
5086
5087        let id = store
5088            .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
5089            .await
5090            .unwrap();
5091        store.update_status(id, TaskStatus::Routed).await.unwrap();
5092        set_task_updated_at_past(&store, id).await;
5093
5094        let task_id = format!("internal:{id}");
5095        let session_name = tmux.session_name("owner/repo", &task_id);
5096        let create_result = tmux
5097            .command()
5098            .args([
5099                "new-session",
5100                "-d",
5101                "-s",
5102                &session_name,
5103                "-c",
5104                "/tmp",
5105                "sleep 60",
5106            ])
5107            .output()
5108            .await;
5109
5110        match create_result {
5111            Ok(o) if o.status.success() => {}
5112            _ => {
5113                eprintln!("Skipping test: tmux not available or failed to create test session");
5114                return;
5115            }
5116        }
5117
5118        let session_map = tmux.batch_session_active().await;
5119        assert_eq!(session_map.get(&session_name).copied(), Some(true));
5120
5121        tick_recover_stuck_tasks(
5122            &backend,
5123            &tmux,
5124            "owner/repo",
5125            &task_manager,
5126            &config,
5127            &store,
5128            &session_map,
5129            &Arc::new(DashMap::new()),
5130        )
5131        .await
5132        .unwrap();
5133
5134        let task = store.get(id).await.unwrap();
5135        assert_eq!(
5136            task.status,
5137            TaskStatus::New,
5138            "routed task blocked by a stale session should be reset for re-routing"
5139        );
5140        assert!(
5141            !tmux.session_exists(&session_name).await,
5142            "recovery should kill the stale tmux session so dispatch can proceed"
5143        );
5144    }
5145
5146    /// An internal complex task that entered in_progress `minutes` ago and was last written
5147    /// `updated_minutes_ago`, with a live tmux session. None when tmux is not available
5148    async fn live_complex_run(
5149        store: &TaskStore,
5150        tmux: &TmuxManager,
5151        minutes: i64,
5152        updated_minutes_ago: i64,
5153    ) -> Option<(i64, String)> {
5154        let id = store
5155            .create_internal("owner/repo", "Long complex run", "", "cron", "1", None)
5156            .await
5157            .unwrap();
5158        store
5159            .set_fields(id, &[("complexity", serde_json::json!("complex"))])
5160            .await
5161            .unwrap();
5162        store
5163            .update_status(id, TaskStatus::InProgress)
5164            .await
5165            .unwrap();
5166        let ago = |m: i64| {
5167            (chrono::Utc::now() - chrono::Duration::minutes(m))
5168                .format("%Y-%m-%dT%H:%M:%SZ")
5169                .to_string()
5170        };
5171        let entered = ago(minutes);
5172        sqlx::query(
5173            "UPDATE task_activity SET timestamp = ? WHERE task_id = ? AND to_status = 'in_progress'",
5174        )
5175        .bind(&entered)
5176        .bind(id)
5177        .execute(store.pool())
5178        .await
5179        .unwrap();
5180        sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
5181            .bind(ago(updated_minutes_ago))
5182            .bind(id)
5183            .execute(store.pool())
5184            .await
5185            .unwrap();
5186
5187        let session_name = tmux.session_name("owner/repo", &format!("internal:{id}"));
5188        let created = tmux
5189            .command()
5190            .args([
5191                "new-session",
5192                "-d",
5193                "-s",
5194                &session_name,
5195                "-c",
5196                "/tmp",
5197                "sleep 60",
5198            ])
5199            .output()
5200            .await;
5201        match created {
5202            Ok(o) if o.status.success() => Some((id, session_name)),
5203            _ => None,
5204        }
5205    }
5206
5207    fn long_run_config() -> EngineConfig {
5208        EngineConfig {
5209            stuck_timeout: 1800,
5210            run_timeouts: RunTimeouts {
5211                base: 1800,
5212                medium: 2700,
5213                complex: 3600,
5214            },
5215            ..EngineConfig::default()
5216        }
5217    }
5218
5219    #[tokio::test]
5220    async fn recover_stuck_tasks_keeps_a_live_complex_run_inside_its_timeout() {
5221        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5222        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5223        let task_manager = Arc::new(TaskManager::with_store(
5224            Arc::clone(&backend),
5225            Arc::clone(&store),
5226            "owner/repo".to_string(),
5227        ));
5228        let tmux = Arc::new(TmuxManager::new());
5229        let Some((id, session_name)) = live_complex_run(&store, &tmux, 31, 31).await else {
5230            eprintln!("Skipping test: tmux not available or failed to create test session");
5231            return;
5232        };
5233        let session_map = tmux.batch_session_active().await;
5234
5235        tick_recover_stuck_tasks(
5236            &backend,
5237            &tmux,
5238            "owner/repo",
5239            &task_manager,
5240            &long_run_config(),
5241            &store,
5242            &session_map,
5243            &Arc::new(DashMap::new()),
5244        )
5245        .await
5246        .unwrap();
5247
5248        let alive = tmux.session_exists(&session_name).await;
5249        let _ = tmux.kill_session(&session_name).await;
5250        assert_eq!(
5251            store.get(id).await.unwrap().status,
5252            TaskStatus::InProgress,
5253            "a complex run at 31 minutes is inside its 3600s timeout"
5254        );
5255        assert!(alive, "recovery must not touch a run inside its timeout");
5256    }
5257
5258    #[tokio::test]
5259    async fn recover_stuck_tasks_kills_a_live_complex_run_past_its_timeout() {
5260        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5261        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5262        let task_manager = Arc::new(TaskManager::with_store(
5263            Arc::clone(&backend),
5264            Arc::clone(&store),
5265            "owner/repo".to_string(),
5266        ));
5267        let tmux = Arc::new(TmuxManager::new());
5268        let Some((id, session_name)) = live_complex_run(&store, &tmux, 66, 0).await else {
5269            eprintln!("Skipping test: tmux not available or failed to create test session");
5270            return;
5271        };
5272        let session_map = tmux.batch_session_active().await;
5273
5274        tick_recover_stuck_tasks(
5275            &backend,
5276            &tmux,
5277            "owner/repo",
5278            &task_manager,
5279            &long_run_config(),
5280            &store,
5281            &session_map,
5282            &Arc::new(DashMap::new()),
5283        )
5284        .await
5285        .unwrap();
5286
5287        let alive = tmux.session_exists(&session_name).await;
5288        let _ = tmux.kill_session(&session_name).await;
5289        assert_eq!(
5290            store.get(id).await.unwrap().status,
5291            TaskStatus::New,
5292            "a complex run past its timeout plus grace is stuck"
5293        );
5294        assert!(
5295            !alive,
5296            "recovery must kill the session it reports as killed"
5297        );
5298    }
5299
5300    // ── tick_dispatch_tasks: deadlock regression test (#1361) ──────────────
5301
5302    /// Regression test for #1361: tick_dispatch_tasks previously accepted
5303    /// `&Arc<RwLock<Router>>` and called `router_arc.read().await` internally.
5304    /// The main loop already held a write lock on the same RwLock, so the read
5305    /// could never be acquired — guaranteed deadlock on every tick with routed
5306    /// tasks.
5307    ///
5308    /// The fix changed the signature to accept `&Router` directly (the caller
5309    /// already has the dereferenced guard), eliminating the lock re-acquisition.
5310    ///
5311    /// This test verifies the function completes within 2 seconds when the
5312    /// caller holds the write lock — the old code would deadlock here.
5313    #[tokio::test]
5314    async fn dispatch_does_not_deadlock_under_write_lock() {
5315        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5316        let mock = MockBackend::new();
5317        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5318        let task_manager = Arc::new(TaskManager::with_store(
5319            Arc::clone(&backend),
5320            Arc::clone(&store),
5321            "owner/repo".to_string(),
5322        ));
5323        let tmux = Arc::new(TmuxManager::new());
5324        let semaphore = Arc::new(Semaphore::new(4));
5325        let (weight_tx, _weight_rx) = mpsc::channel(16);
5326        let dispatching = Arc::new(DashMap::new());
5327        let transport = Arc::new(Transport::new());
5328        let capture = Arc::new(CaptureService::new(transport));
5329
5330        let router_config = RouterConfig::default();
5331        let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5332        let router_arc = Arc::new(RwLock::new(router));
5333
5334        // Simulate what the main loop does: hold a write lock, snapshot
5335        // dispatch mode, then call tick_dispatch_tasks without passing any
5336        // lock guard into async dispatch work.
5337        let write_guard = router_arc.write().await;
5338        let dispatch_mode = dispatch_mode_from_router(&write_guard);
5339
5340        let result = time::timeout(
5341            Duration::from_secs(2),
5342            tick_dispatch_tasks(
5343                &backend,
5344                &tmux,
5345                "owner/repo",
5346                &Arc::new(TaskRunner::new("owner/repo".to_string())),
5347                &capture,
5348                &semaphore,
5349                &task_manager,
5350                &weight_tx,
5351                dispatch_mode,
5352                &dispatching,
5353                &store,
5354                &HashMap::new(),
5355            ),
5356        )
5357        .await;
5358
5359        assert!(
5360            result.is_ok(),
5361            "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5362             on router_arc while a write lock was already held (issue #1361)"
5363        );
5364    }
5365
5366    /// Regression test for #3599: a task routed to an agent/model that entered
5367    /// cooldown *after* routing but *before* dispatch must not be dispatched —
5368    /// the stale route was decided before the failure that triggered the
5369    /// cooldown was known. Dispatch must re-check the assigned agent/model
5370    /// against the same cooldown store used at routing time and, if stale,
5371    /// leave the task in `Routed` for the next tick to re-evaluate (no PR is
5372    /// spawned, no per-task defer timer is written).
5373    #[tokio::test]
5374    async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5375        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5376        let mock = MockBackend::new();
5377        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5378        let task_manager = Arc::new(TaskManager::with_store(
5379            Arc::clone(&backend),
5380            Arc::clone(&store),
5381            "owner/repo".to_string(),
5382        ));
5383        let tmux = Arc::new(TmuxManager::new());
5384        let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5385        let semaphore = Arc::new(Semaphore::new(4));
5386        let (weight_tx, _weight_rx) = mpsc::channel(16);
5387        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5388        let transport = Arc::new(Transport::new());
5389        let capture = Arc::new(CaptureService::new(transport));
5390
5391        let id = store
5392            .upsert_external(&UpsertExternal {
5393                repo: "owner/repo",
5394                ext_id: "999",
5395                title: "Stale routed task",
5396                body: "",
5397                author: "user",
5398                url: "https://github.com/owner/repo/issues/999",
5399                labels: &[],
5400                origin: "github",
5401            })
5402            .await
5403            .unwrap();
5404        store
5405            .store_route(&StoreRoute {
5406                id,
5407                agent: "minimax",
5408                model: Some("opus"),
5409                complexity: "medium",
5410                estimate: 0,
5411                reason: "test",
5412                profile: "{}",
5413                skills: "[]",
5414            })
5415            .await
5416            .unwrap();
5417        store.update_status(id, DbStatus::Routed).await.unwrap();
5418
5419        // Simulate a billing/rate-limit cooldown recorded for minimax:opus after
5420        // this task was routed but before it dispatched.
5421        cooldown::set_model_cooldown("minimax", "opus", 300, "rate_limit_usage_window").await;
5422
5423        let dispatch_mode = DispatchMode {
5424            is_degraded: false,
5425            healthy_agents: 1,
5426            threshold: 1,
5427        };
5428
5429        tick_dispatch_tasks(
5430            &backend,
5431            &tmux,
5432            "owner/repo",
5433            &runner,
5434            &capture,
5435            &semaphore,
5436            &task_manager,
5437            &weight_tx,
5438            dispatch_mode,
5439            &dispatching,
5440            &store,
5441            &HashMap::new(),
5442        )
5443        .await
5444        .unwrap();
5445
5446        let task = store.get(id).await.unwrap();
5447        assert_eq!(
5448            task.status,
5449            DbStatus::Routed,
5450            "task whose routed model is now cooled down must stay Routed, not be dispatched"
5451        );
5452        assert!(
5453            dispatching.is_empty(),
5454            "dispatch guard must be released when skipping a stale-cooldown task"
5455        );
5456    }
5457
5458    #[tokio::test]
5459    async fn dispatch_skips_a_task_with_an_open_dependency() {
5460        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5461        let mock = MockBackend::new();
5462        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5463        let task_manager = Arc::new(TaskManager::with_store(
5464            Arc::clone(&backend),
5465            Arc::clone(&store),
5466            "owner/repo".to_string(),
5467        ));
5468        let tmux = Arc::new(TmuxManager::new());
5469        let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5470        let semaphore = Arc::new(Semaphore::new(4));
5471        let (weight_tx, _weight_rx) = mpsc::channel(16);
5472        let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5473        let transport = Arc::new(Transport::new());
5474        let capture = Arc::new(CaptureService::new(transport));
5475
5476        let id = store
5477            .upsert_external(&UpsertExternal {
5478                repo: "owner/repo",
5479                ext_id: "42",
5480                title: "Waiting routed task",
5481                body: "",
5482                author: "user",
5483                url: "https://github.com/owner/repo/issues/42",
5484                labels: &[],
5485                origin: "github",
5486            })
5487            .await
5488            .unwrap();
5489        store
5490            .replace_dependencies(
5491                id,
5492                &[NewDependency {
5493                    repo: "owner/repo".into(),
5494                    number: 7,
5495                    source: DepSource::Github,
5496                }],
5497            )
5498            .await
5499            .unwrap();
5500        store
5501            .store_route(&StoreRoute {
5502                id,
5503                agent: "claude",
5504                model: Some("haiku"),
5505                complexity: "simple",
5506                estimate: 0,
5507                reason: "test",
5508                profile: "{}",
5509                skills: "[]",
5510            })
5511            .await
5512            .unwrap();
5513        store.update_status(id, DbStatus::Routed).await.unwrap();
5514
5515        let dispatch_mode = DispatchMode {
5516            is_degraded: false,
5517            healthy_agents: 1,
5518            threshold: 1,
5519        };
5520        tick_dispatch_tasks(
5521            &backend,
5522            &tmux,
5523            "owner/repo",
5524            &runner,
5525            &capture,
5526            &semaphore,
5527            &task_manager,
5528            &weight_tx,
5529            dispatch_mode,
5530            &dispatching,
5531            &store,
5532            &HashMap::new(),
5533        )
5534        .await
5535        .unwrap();
5536
5537        let task = store.get(id).await.unwrap();
5538        assert_eq!(task.status, DbStatus::Waiting);
5539        assert!(dispatching.is_empty());
5540    }
5541
5542    /// Regression test for the completed-session -> stuck-task reclaim race.
5543    ///
5544    /// Scenario:
5545    /// - Runner finishes a tmux session and will call store_touch_updated_at shortly after
5546    /// - Engine's tick may observe the dead session in Phase 1 and Phase 2 may run
5547    ///   stuck-task recovery before the runner touches the store, causing incorrect reclaim
5548    /// Fix: tick_check_session_completions now touches the store.updated_at when it
5549    /// observes a completed session, preventing reclaim. This test simulates the
5550    /// timing by creating a session, marking the corresponding task InProgress with
5551    /// an old updated_at, calling tick_check_session_completions (which should touch
5552    /// the store), then calling tick_recover_stuck_tasks and asserting the task
5553    /// remains InProgress.
5554    #[tokio::test]
5555    async fn completed_session_does_not_cause_stuck_reclaim_race() {
5556        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5557        let mock = MockBackend::new();
5558        let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5559        let task_manager = Arc::new(TaskManager::with_store(
5560            Arc::clone(&backend),
5561            Arc::clone(&store),
5562            "owner/repo".to_string(),
5563        ));
5564        let tmux = Arc::new(TmuxManager::new());
5565        let transport = Arc::new(Transport::new());
5566        let capture = Arc::new(CaptureService::new(transport));
5567
5568        // Insert external task and set to InProgress with old updated_at
5569        let id = store
5570            .upsert_external(&crate::store::UpsertExternal {
5571                repo: "owner/repo",
5572                ext_id: "200",
5573                title: "Race Task",
5574                body: "",
5575                author: "",
5576                url: "",
5577                labels: &[],
5578                origin: "github",
5579            })
5580            .await
5581            .unwrap();
5582        store
5583            .update_status(id, TaskStatus::InProgress)
5584            .await
5585            .unwrap();
5586
5587        // set updated_at to far past so it would normally be eligible for reclaim
5588        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5589            .bind(id)
5590            .execute(store.pool())
5591            .await
5592            .unwrap();
5593
5594        // Register a real tmux session for this task and then exit it so it's observed dead
5595        let task_id = "200";
5596        let session_name = tmux.session_name("owner/repo", task_id);
5597        let create_result = tmux
5598            .command()
5599            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5600            .output()
5601            .await;
5602        match create_result {
5603            Ok(o) if o.status.success() => {}
5604            _ => {
5605                eprintln!("Skipping test: tmux not available or failed to create test session");
5606                return;
5607            }
5608        }
5609        let _guard = SessionGuard(session_name.clone());
5610
5611        // Ensure remain-on-exit so the session lingers as dead long enough to be snapshot
5612        let _ = tmux
5613            .command()
5614            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5615            .output()
5616            .await;
5617        let _ = tmux
5618            .command()
5619            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5620            .output()
5621            .await;
5622
5623        // Poll until pane is dead
5624        for _ in 0..20 {
5625            time::sleep(Duration::from_millis(100)).await;
5626            if !tmux.session_is_running(&session_name).await {
5627                break;
5628            }
5629        }
5630
5631        // Register the session in capture (engine does this during dispatch)
5632        let task_key = task_id.to_string();
5633        capture
5634            .register_session("owner/repo", &task_key, &session_name)
5635            .await;
5636
5637        // Call phase1 (now touches store.updated_at for completed session)
5638        let config = EngineConfig {
5639            no_session_stuck_timeout: 600,
5640            stuck_timeout: 1800,
5641            ..EngineConfig::default()
5642        };
5643
5644        tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5645            .await
5646            .unwrap();
5647
5648        // Verify phase1 actually touched updated_at. It only processes sessions
5649        // that appear in snapshot() (list_sessions + batch_session_active). On CI,
5650        // if the session was already fully removed before the snapshot runs (e.g.
5651        // remain-on-exit semantics differ across tmux versions), phase1 won't see
5652        // it and the store won't be touched. Skip in that case.
5653        {
5654            let task_state = store.get(id).await.unwrap();
5655            if task_state.updated_at.starts_with("2020") {
5656                eprintln!(
5657                    "Skipping test: tick_check_session_completions did not update stored \
5658                     updated_at — session was not in snapshot (CI tmux environment issue)"
5659                );
5660                return;
5661            }
5662        }
5663
5664        // Now run phase2 — the updated_at should have been touched so no reclaim occurs
5665        tick_recover_stuck_tasks(
5666            &backend,
5667            &tmux,
5668            "owner/repo",
5669            &task_manager,
5670            &config,
5671            &store,
5672            &HashMap::new(),
5673            &Arc::new(DashMap::new()),
5674        )
5675        .await
5676        .unwrap();
5677
5678        // Verify the task is still InProgress in the store
5679        let task = store.get(id).await.unwrap();
5680        assert_eq!(
5681            task.status,
5682            TaskStatus::InProgress,
5683            "task should not be reclaimed to New"
5684        );
5685    }
5686
5687    /// Regression test: a repo configured with a ".git" suffix (e.g. "owner/repo.git")
5688    /// must still match its own live tmux sessions in `tick_check_session_completions`.
5689    ///
5690    /// `TmuxManager::session_name` strips ".git" when creating the session, so a live
5691    /// session's parsed `project` field never carries it. Before the fix, this
5692    /// function's `repo_name` derivation skipped the `.trim_end_matches(".git")` step,
5693    /// so `session.project` ("repo") never equaled `repo_name` ("repo.git") and the
5694    /// completed-session touch/kill was silently skipped for the entire repo — leaving
5695    /// the coarser-grained stuck-task reclaim as the only (racy) safety net.
5696    #[tokio::test]
5697    async fn session_completion_matches_repo_configured_with_git_suffix() {
5698        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5699        let tmux = Arc::new(TmuxManager::new());
5700        let transport = Arc::new(Transport::new());
5701        let capture = Arc::new(CaptureService::new(transport));
5702
5703        let repo = "owner/repo.git";
5704        let id = store
5705            .upsert_external(&crate::store::UpsertExternal {
5706                repo,
5707                ext_id: "201",
5708                title: "Git-suffix repo task",
5709                body: "",
5710                author: "",
5711                url: "",
5712                labels: &[],
5713                origin: "github",
5714            })
5715            .await
5716            .unwrap();
5717        store
5718            .update_status(id, TaskStatus::InProgress)
5719            .await
5720            .unwrap();
5721        sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5722            .bind(id)
5723            .execute(store.pool())
5724            .await
5725            .unwrap();
5726
5727        // session_name strips ".git" internally — this is the real session a dispatch
5728        // for this repo would have created.
5729        let task_id = "201";
5730        let session_name = tmux.session_name(repo, task_id);
5731        let create_result = tmux
5732            .command()
5733            .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5734            .output()
5735            .await;
5736        match create_result {
5737            Ok(o) if o.status.success() => {}
5738            _ => {
5739                eprintln!("Skipping test: tmux not available or failed to create test session");
5740                return;
5741            }
5742        }
5743        let _guard = SessionGuard(session_name.clone());
5744
5745        let _ = tmux
5746            .command()
5747            .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5748            .output()
5749            .await;
5750        let _ = tmux
5751            .command()
5752            .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5753            .output()
5754            .await;
5755
5756        for _ in 0..20 {
5757            time::sleep(Duration::from_millis(100)).await;
5758            if !tmux.session_is_running(&session_name).await {
5759                break;
5760            }
5761        }
5762
5763        capture.register_session(repo, task_id, &session_name).await;
5764
5765        tick_check_session_completions(&tmux, repo, &capture, &store)
5766            .await
5767            .unwrap();
5768
5769        let task_state = store.get(id).await.unwrap();
5770        if task_state.updated_at.starts_with("2020") {
5771            eprintln!(
5772                "Skipping test: tick_check_session_completions did not update stored \
5773                 updated_at — session was not in snapshot (CI tmux environment issue)"
5774            );
5775            return;
5776        }
5777        assert!(
5778            !task_state.updated_at.starts_with("2020"),
5779            "tick_check_session_completions must match its own repo's live sessions \
5780             even when the repo is configured with a \".git\" suffix"
5781        );
5782    }
5783
5784    // ── tick_job_scheduler: resilience to bad project configs ────────────────
5785
5786    /// A project with a duplicate job id (same id in .orch.yml AND prompts/jobs/)
5787    /// must not cause tick_job_scheduler to return Err — it should log an error
5788    /// and return Ok(()) so other projects are unaffected.
5789    #[tokio::test]
5790    async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5791        let tmp = tempfile::tempdir().unwrap();
5792        let cfg = tmp.path().join(".orch.yml");
5793        fs::write(
5794            &cfg,
5795            "jobs:\n  - id: trading-scan\n    schedule: \"0 9 * * *\"\n    task:\n      title: Scan\n      body: do it\n",
5796        )
5797        .unwrap();
5798        let jobs_dir = tmp.path().join("prompts").join("jobs");
5799        fs::create_dir_all(&jobs_dir).unwrap();
5800        fs::write(
5801            jobs_dir.join("trading-scan.md"),
5802            "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5803        )
5804        .unwrap();
5805
5806        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5807        let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5808        assert!(
5809            result.is_ok(),
5810            "duplicate job id must not propagate as Err: {result:?}"
5811        );
5812    }
5813
5814    /// When one project has a duplicate job id (bad config) and a second project
5815    /// has a valid config, the scheduler tick for the second project must still
5816    /// succeed — simulating the per-project loop in the engine.
5817    #[tokio::test]
5818    async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5819        // Bad project: duplicate id across .orch.yml and prompts/jobs/
5820        let bad_tmp = tempfile::tempdir().unwrap();
5821        let bad_cfg = bad_tmp.path().join(".orch.yml");
5822        fs::write(
5823            &bad_cfg,
5824            "jobs:\n  - id: dup\n    schedule: \"0 9 * * *\"\n    task:\n      title: Dup\n      body: x\n",
5825        )
5826        .unwrap();
5827        let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5828        fs::create_dir_all(&bad_jobs_dir).unwrap();
5829        fs::write(
5830            bad_jobs_dir.join("dup.md"),
5831            "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5832        )
5833        .unwrap();
5834
5835        // Good project: valid config, no duplicate
5836        let good_tmp = tempfile::tempdir().unwrap();
5837        let good_cfg = good_tmp.path().join(".orch.yml");
5838        fs::write(
5839            &good_cfg,
5840            "jobs:\n  - id: valid-job\n    schedule: \"0 9 * * *\"\n    task:\n      title: Valid\n      body: ok\n",
5841        )
5842        .unwrap();
5843
5844        let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5845
5846        let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5847        assert!(
5848            bad_result.is_ok(),
5849            "bad project must not abort scheduler: {bad_result:?}"
5850        );
5851
5852        let good_result =
5853            tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5854        assert!(
5855            good_result.is_ok(),
5856            "valid project must still run after bad project: {good_result:?}"
5857        );
5858    }
5859
5860    // ── route_new_tasks_global ───────────────────────────────────────────────
5861
5862    /// Regression test for #3407: tasks from inactive/removed repos were stuck
5863    /// in `new` indefinitely because `tick_route_tasks` is repo-scoped.
5864    ///
5865    /// `route_new_tasks_global` must route tasks whose repo != current_repo
5866    /// and leave current-repo tasks untouched.
5867    #[serial_test::serial(cooldown_state)]
5868    #[tokio::test]
5869    async fn global_sweep_routes_inactive_repo_tasks() {
5870        cooldown::reset_global_state().await;
5871
5872        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5873
5874        // Active repo task — must NOT be touched by the global sweep.
5875        let active_id = store
5876            .upsert_external(&UpsertExternal {
5877                repo: "active/repo",
5878                ext_id: "1",
5879                title: "Active task",
5880                body: "",
5881                author: "user",
5882                url: "https://github.com/active/repo/issues/1",
5883                labels: &[],
5884                origin: "github",
5885            })
5886            .await
5887            .unwrap();
5888
5889        // Inactive repo task — must be routed by the global sweep.
5890        let inactive_id = store
5891            .upsert_external(&UpsertExternal {
5892                repo: "inactive/repo",
5893                ext_id: "42",
5894                title: "Orphaned task",
5895                body: "",
5896                author: "user",
5897                url: "https://github.com/inactive/repo/issues/42",
5898                labels: &[],
5899                origin: "github",
5900            })
5901            .await
5902            .unwrap();
5903
5904        assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5905        assert_eq!(
5906            store.get(inactive_id).await.unwrap().status,
5907            TaskStatus::New
5908        );
5909
5910        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5911        let mut tier = HashMap::new();
5912        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5913        model_map.insert("medium".to_string(), tier);
5914        let config = RouterConfig {
5915            mode: "round_robin".to_string(),
5916            agents: vec!["claude".to_string()],
5917            model_map,
5918            ..Default::default()
5919        };
5920        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5921
5922        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
5923        let mut routing_quota = RoutingTickQuota::new(1);
5924        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5925            .await
5926            .unwrap();
5927
5928        // Active-repo task must remain new (global sweep skips it).
5929        assert_eq!(
5930            store.get(active_id).await.unwrap().status,
5931            TaskStatus::New,
5932            "global sweep must not touch tasks for the active repo"
5933        );
5934
5935        // Inactive-repo task must now be routed.
5936        let after = store.get(inactive_id).await.unwrap();
5937        assert_eq!(
5938            after.status,
5939            TaskStatus::Routed,
5940            "global sweep must route tasks from inactive repos"
5941        );
5942        assert!(
5943            matches!(&after.agent, Some(a) if !a.is_empty()),
5944            "agent must be set after routing"
5945        );
5946    }
5947
5948    /// Regression test for #3523: `route_new_tasks_global` previously filtered only
5949    /// `t.repo != current_repo`, so during repo A's tick it would route repo B's `new`
5950    /// tasks too — even though B is an active, non-orphaned project with its own tick
5951    /// scheduled later in the same pass. This is the exact "racing the real per-repo
5952    /// dispatch" failure mode `dispatch_routed_tasks_global` was fixed for in #3415;
5953    /// the route-side sweep needs the same `active_repos` scoping, not just the
5954    /// current tick's own repo.
5955    #[serial_test::serial(cooldown_state)]
5956    #[tokio::test]
5957    async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5958        cooldown::reset_global_state().await;
5959
5960        let store = Arc::new(TaskStore::open_memory().await.unwrap());
5961
5962        // Belongs to a second active repo — NOT the repo whose tick is running,
5963        // but still a currently-configured project, not an orphan.
5964        let other_active_id = store
5965            .upsert_external(&UpsertExternal {
5966                repo: "other-active/repo",
5967                ext_id: "7",
5968                title: "Other active project's task",
5969                body: "",
5970                author: "user",
5971                url: "https://github.com/other-active/repo/issues/7",
5972                labels: &[],
5973                origin: "github",
5974            })
5975            .await
5976            .unwrap();
5977
5978        // Genuinely orphaned repo — not in the active set at all.
5979        let inactive_id = store
5980            .upsert_external(&UpsertExternal {
5981                repo: "inactive/repo",
5982                ext_id: "42",
5983                title: "Orphaned task",
5984                body: "",
5985                author: "user",
5986                url: "https://github.com/inactive/repo/issues/42",
5987                labels: &[],
5988                origin: "github",
5989            })
5990            .await
5991            .unwrap();
5992
5993        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5994        let mut tier = HashMap::new();
5995        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5996        model_map.insert("medium".to_string(), tier);
5997        let config = RouterConfig {
5998            mode: "round_robin".to_string(),
5999            agents: vec!["claude".to_string()],
6000            model_map,
6001            ..Default::default()
6002        };
6003        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6004
6005        // Simulate "active/repo"'s tick running the global sweep. Both
6006        // "active/repo" and "other-active/repo" are currently-configured
6007        // projects; only "inactive/repo" is orphaned.
6008        let active_repos: HashSet<String> =
6009            ["active/repo".to_string(), "other-active/repo".to_string()]
6010                .into_iter()
6011                .collect();
6012        let mut routing_quota = RoutingTickQuota::new(1);
6013        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6014            .await
6015            .unwrap();
6016
6017        assert_eq!(
6018            store.get(other_active_id).await.unwrap().status,
6019            TaskStatus::New,
6020            "global sweep run from a different active repo's tick must not route \
6021             another active repo's new tasks — that repo's own tick will route them"
6022        );
6023        assert_eq!(
6024            store.get(inactive_id).await.unwrap().status,
6025            TaskStatus::Routed,
6026            "global sweep must still route tasks from genuinely orphaned repos"
6027        );
6028    }
6029
6030    /// Tasks with the `no-agent` label must be skipped by the global sweep.
6031    #[serial_test::serial(cooldown_state)]
6032    #[tokio::test]
6033    async fn global_sweep_skips_no_agent_tasks() {
6034        cooldown::reset_global_state().await;
6035
6036        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6037
6038        let id = store
6039            .upsert_external(&UpsertExternal {
6040                repo: "inactive/repo",
6041                ext_id: "99",
6042                title: "No-agent task",
6043                body: "",
6044                author: "user",
6045                url: "https://github.com/inactive/repo/issues/99",
6046                labels: &["no-agent".to_string()],
6047                origin: "github",
6048            })
6049            .await
6050            .unwrap();
6051
6052        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6053        let mut tier = HashMap::new();
6054        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6055        model_map.insert("medium".to_string(), tier);
6056        let config = RouterConfig {
6057            mode: "round_robin".to_string(),
6058            agents: vec!["claude".to_string()],
6059            model_map,
6060            ..Default::default()
6061        };
6062        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6063
6064        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6065        let mut routing_quota = RoutingTickQuota::new(1);
6066        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6067            .await
6068            .unwrap();
6069
6070        assert_eq!(
6071            store.get(id).await.unwrap().status,
6072            TaskStatus::New,
6073            "no-agent tasks must not be routed"
6074        );
6075    }
6076
6077    /// Regression test for #3615: the routing limit must be consumed by a
6078    /// shared per-engine-tick quota, not independently by every repo/global
6079    /// routing pass. Otherwise one slow router timeout per pass can stack into
6080    /// a multi-minute watchdog stall.
6081    #[serial_test::serial(cooldown_state)]
6082    #[tokio::test]
6083    async fn global_sweep_honors_shared_routing_quota() {
6084        cooldown::reset_global_state().await;
6085
6086        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6087        for ext_id in ["1", "2"] {
6088            store
6089                .upsert_external(&UpsertExternal {
6090                    repo: "inactive/repo",
6091                    ext_id,
6092                    title: "Orphaned task",
6093                    body: "",
6094                    author: "user",
6095                    url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
6096                    labels: &[],
6097                    origin: "github",
6098                })
6099                .await
6100                .unwrap();
6101        }
6102
6103        let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6104        let mut tier = HashMap::new();
6105        tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6106        model_map.insert("medium".to_string(), tier);
6107        let config = RouterConfig {
6108            mode: "round_robin".to_string(),
6109            agents: vec!["claude".to_string()],
6110            model_map,
6111            ..Default::default()
6112        };
6113        let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6114
6115        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6116        let mut routing_quota = RoutingTickQuota::new(1);
6117        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6118            .await
6119            .unwrap();
6120
6121        let all_tasks = store.list_all_global().await.unwrap();
6122        let routed = all_tasks
6123            .iter()
6124            .filter(|task| task.status == TaskStatus::Routed)
6125            .count();
6126        let still_new = all_tasks
6127            .iter()
6128            .filter(|task| task.status == TaskStatus::New)
6129            .count();
6130
6131        assert_eq!(routed, 1, "only one task may consume the shared quota");
6132        assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
6133
6134        route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6135            .await
6136            .unwrap();
6137        let after_exhausted = store.list_all_global().await.unwrap();
6138        assert_eq!(
6139            after_exhausted
6140                .iter()
6141                .filter(|task| task.status == TaskStatus::Routed)
6142                .count(),
6143            1,
6144            "an exhausted shared quota must not reset within the same tick"
6145        );
6146    }
6147
6148    // ── dispatch_routed_tasks_global ─────────────────────────────────────────
6149    //
6150    // `dispatch_routed_tasks_global` itself is deliberately not exercised
6151    // end-to-end here: once it finds an orphaned repo, it spawns the real
6152    // runner, which creates a real tmux session and touches the network.
6153    // The existing `dispatch_does_not_deadlock_under_write_lock` test avoids
6154    // this same hazard by keeping the dispatchable set empty. The selection
6155    // logic (which tasks belong to which repo) is pulled into the pure
6156    // `group_orphaned_routed_tasks` helper below so it can be tested without
6157    // those side effects.
6158
6159    /// Regression test for #3413: tasks routed for an inactive/removed repo were
6160    /// stuck in `routed` indefinitely because `tick_dispatch_tasks` is repo-scoped.
6161    ///
6162    /// `group_orphaned_routed_tasks` must select tasks whose repo != the active
6163    /// repo, group them by repo, and leave active-repo tasks out entirely.
6164    #[tokio::test]
6165    async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
6166        async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6167            let id = store
6168                .upsert_external(&UpsertExternal {
6169                    repo,
6170                    ext_id,
6171                    title: "Routed task",
6172                    body: "",
6173                    author: "user",
6174                    url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6175                    labels: &[],
6176                    origin: "github",
6177                })
6178                .await
6179                .unwrap();
6180            store
6181                .store_route(&StoreRoute {
6182                    id,
6183                    agent: "claude",
6184                    model: Some("sonnet"),
6185                    complexity: "medium",
6186                    estimate: 0,
6187                    reason: "test",
6188                    profile: "{}",
6189                    skills: "[]",
6190                })
6191                .await
6192                .unwrap();
6193            store.update_status(id, TaskStatus::Routed).await.unwrap();
6194            id
6195        }
6196
6197        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6198
6199        let active_id = make_routed(&store, "active/repo", "1").await;
6200        let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
6201        let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6202        let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6203
6204        let all_routed = store
6205            .list_all_by_status_global(TaskStatus::Routed)
6206            .await
6207            .unwrap();
6208        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6209        let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6210
6211        assert!(
6212            !by_repo.contains_key("active/repo"),
6213            "global sweep must not select tasks for the active repo"
6214        );
6215        let inactive_ids: HashSet<i64> = by_repo
6216            .get("inactive/repo")
6217            .expect("inactive/repo must be selected")
6218            .iter()
6219            .map(|t| t.id)
6220            .collect();
6221        assert_eq!(
6222            inactive_ids,
6223            [inactive_id_a, inactive_id_b].into_iter().collect(),
6224            "global sweep must group all orphaned tasks for a repo together"
6225        );
6226        assert_eq!(
6227            by_repo
6228                .get("other-inactive/repo")
6229                .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6230            Some(vec![other_inactive_id])
6231        );
6232
6233        // Sanity: the active-repo task really was created and routed, it's just
6234        // excluded from the selection above.
6235        assert_eq!(
6236            store.get(active_id).await.unwrap().status,
6237            TaskStatus::Routed
6238        );
6239    }
6240
6241    /// Regression test for the PR #3413 review finding: a single `current_repo` string is
6242    /// the wrong exclusion — repo B's routed tasks must not look "orphaned" during repo A's
6243    /// dispatch just because A != B.
6244    /// `group_orphaned_routed_tasks` must exclude every repo in the full active set.
6245    #[tokio::test]
6246    async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6247        async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6248            let id = store
6249                .upsert_external(&UpsertExternal {
6250                    repo,
6251                    ext_id,
6252                    title: "Routed task",
6253                    body: "",
6254                    author: "user",
6255                    url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6256                    labels: &[],
6257                    origin: "github",
6258                })
6259                .await
6260                .unwrap();
6261            store
6262                .store_route(&StoreRoute {
6263                    id,
6264                    agent: "claude",
6265                    model: Some("sonnet"),
6266                    complexity: "medium",
6267                    estimate: 0,
6268                    reason: "test",
6269                    profile: "{}",
6270                    skills: "[]",
6271                })
6272                .await
6273                .unwrap();
6274            store.update_status(id, TaskStatus::Routed).await.unwrap();
6275            id
6276        }
6277
6278        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6279
6280        let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6281        let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6282        let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6283
6284        let all_routed = store
6285            .list_all_by_status_global(TaskStatus::Routed)
6286            .await
6287            .unwrap();
6288        // Simulate repo A's tick: current repo is "active/repo-a", but the full
6289        // active project set also includes "active/repo-b".
6290        let active_repos: HashSet<String> =
6291            ["active/repo-a".to_string(), "active/repo-b".to_string()]
6292                .into_iter()
6293                .collect();
6294        let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6295
6296        assert!(
6297            !by_repo.contains_key("active/repo-a"),
6298            "the current repo's own tasks must not be swept"
6299        );
6300        assert!(
6301            !by_repo.contains_key("active/repo-b"),
6302            "another active repo's tasks must not be swept during repo A's tick"
6303        );
6304        assert_eq!(
6305            by_repo
6306                .get("inactive/repo")
6307                .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6308            Some(vec![orphaned_id]),
6309            "a genuinely inactive repo's tasks must still be swept"
6310        );
6311
6312        // Sanity: repo A and repo B tasks were really created and routed, just excluded.
6313        assert_eq!(
6314            store.get(repo_a_id).await.unwrap().status,
6315            TaskStatus::Routed
6316        );
6317        assert_eq!(
6318            store.get(repo_b_id).await.unwrap().status,
6319            TaskStatus::Routed
6320        );
6321    }
6322
6323    /// Tasks with the `no-agent` label must be skipped by the global dispatch sweep.
6324    #[tokio::test]
6325    async fn global_dispatch_sweep_skips_no_agent_tasks() {
6326        let store = Arc::new(TaskStore::open_memory().await.unwrap());
6327
6328        let id = store
6329            .upsert_external(&UpsertExternal {
6330                repo: "inactive/repo",
6331                ext_id: "99",
6332                title: "No-agent routed task",
6333                body: "",
6334                author: "user",
6335                url: "https://github.com/inactive/repo/issues/99",
6336                labels: &["no-agent".to_string()],
6337                origin: "github",
6338            })
6339            .await
6340            .unwrap();
6341        store
6342            .store_route(&StoreRoute {
6343                id,
6344                agent: "claude",
6345                model: Some("sonnet"),
6346                complexity: "medium",
6347                estimate: 0,
6348                reason: "test",
6349                profile: "{}",
6350                skills: "[]",
6351            })
6352            .await
6353            .unwrap();
6354        store.update_status(id, TaskStatus::Routed).await.unwrap();
6355
6356        // Safe to call the full sweep here: with the only orphaned task filtered
6357        // out by the no-agent label, `group_orphaned_routed_tasks` returns an
6358        // empty map and `dispatch_routed_tasks_global` returns before ever
6359        // constructing a backend or spawning a runner.
6360        let tmux = Arc::new(TmuxManager::new());
6361        let transport = Arc::new(Transport::new());
6362        let capture = Arc::new(CaptureService::new(transport));
6363        let semaphore = Arc::new(Semaphore::new(4));
6364        let (weight_tx, _weight_rx) = mpsc::channel(16);
6365        let dispatching = Arc::new(DashMap::new());
6366        let dispatch_mode = DispatchMode {
6367            is_degraded: false,
6368            healthy_agents: 1,
6369            threshold: 1,
6370        };
6371
6372        let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6373        dispatch_routed_tasks_global(
6374            &tmux,
6375            &capture,
6376            &semaphore,
6377            &weight_tx,
6378            dispatch_mode,
6379            &dispatching,
6380            &store,
6381            &HashMap::new(),
6382            &active_repos,
6383        )
6384        .await
6385        .unwrap();
6386
6387        assert_eq!(
6388            store.get(id).await.unwrap().status,
6389            TaskStatus::Routed,
6390            "no-agent tasks must not be dispatched"
6391        );
6392    }
6393}