Skip to main content

orch/engine/
tick.rs

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