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