Skip to main content

orch/engine/
tick.rs

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