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