Skip to main content

orch/engine/
tick.rs

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