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