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