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