Skip to main content

orch/engine/router/
mod.rs

1//! Agent router — selects the best agent and model for each task.
2//!
3//! The router uses LLM-based classification to route tasks to the best agent
4//! (claude, codex, opencode, kimi, or minimax) based on task content, labels,
5//! and configured routing rules. It also generates a specialized agent profile.
6//!
7//! Routing logic (in priority order):
8//! 1. Check for `agent:*` label on task — use that agent directly
9//! 2. If weighted_round_robin enabled, select by capacity-weighted probability
10//! 3. If round_robin mode, cycle through agents (stateful, skips last-used)
11//! 4. Call LLM classifier for intelligent routing
12//! 5. After N LLM failures, fall back to round-robin
13//! 6. Track last routed agent to distribute load across agents
14
15pub mod config;
16mod llm;
17mod ollama;
18mod selection;
19mod strategies;
20pub mod weights;
21
22pub use config::{parse_pool_entry, RouterConfig};
23pub use weights::AgentWeights;
24
25fn fmt_degraded(degraded: &[String]) -> String {
26    if degraded.is_empty() {
27        String::new()
28    } else {
29        format!(" (degraded: {})", degraded.join(", "))
30    }
31}
32
33/// Error returned when all agents/models are in cooldown and routing is temporarily unavailable.
34///
35/// This is a transient error that should not count against `max_route_attempts` since
36/// no agent was actually invoked. The caller should back off and retry later.
37#[derive(Debug, thiserror::Error)]
38pub enum AllAgentsCooledError {
39    #[error("all agents/models in cooldown for {scope}{}", fmt_degraded(.degraded))]
40    AllCooled {
41        scope: String,
42        remaining_secs: Option<u64>,
43        degraded: Vec<String>,
44    },
45}
46
47impl AllAgentsCooledError {
48    /// Create a new error for the given scope, naming currently degraded agents
49    pub fn new(scope: String, remaining_secs: Option<u64>) -> Self {
50        Self::AllCooled {
51            scope,
52            remaining_secs,
53            degraded: list_degraded_agents()
54                .into_iter()
55                .map(|(agent, _reason)| agent)
56                .collect(),
57        }
58    }
59
60    // LLM bypass helpers moved to `impl Router` so they can access Router fields.
61
62    /// Get the scope of the cooldown (e.g., "medium", "all agents", "router pool").
63    pub fn scope(&self) -> &str {
64        match self {
65            Self::AllCooled { scope, .. } => scope,
66        }
67    }
68
69    /// Remaining cooldown duration in seconds when available.
70    pub fn remaining_secs(&self) -> Option<u64> {
71        match self {
72            Self::AllCooled { remaining_secs, .. } => *remaining_secs,
73        }
74    }
75
76    /// Agents currently marked degraded, shown by `orch cooldown list`
77    pub fn degraded(&self) -> &[String] {
78        match self {
79            Self::AllCooled { degraded, .. } => degraded,
80        }
81    }
82}
83
84/// Typed error returned by [`get_route_result`].
85#[derive(Debug, thiserror::Error)]
86pub enum RouteResultError {
87    /// The task has no agent field — it needs to be re-routed.
88    #[error("no agent configured for task {task_id}")]
89    NoAgent { task_id: String },
90    /// Any other failure (store lookup, SQL, etc.).
91    #[error(transparent)]
92    Other(#[from] anyhow::Error),
93}
94
95use crate::backends::ExternalTask;
96use crate::engine::cooldown::{
97    cooldown_until, is_agent_degraded, is_agent_in_cooldown, is_model_in_cooldown,
98    list_degraded_agents, record_agent_success, refresh_degraded_agents, sync_from_kv,
99};
100use crate::store::{get_task_field_direct, store_log_activity, TaskStore};
101use serde::{Deserialize, Serialize};
102use std::sync::Arc;
103
104use llm::{LlmRouter, TIMEOUT_PREFIX};
105
106/// Result of routing a task to an agent.
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
108pub struct RouteResult {
109    /// The selected agent: "claude", "codex", or "opencode"
110    pub agent: String,
111    /// Optional model suggestion, e.g., "claude-sonnet-4-6", "claude-opus-4-6", "o3"
112    pub model: Option<String>,
113    /// Complexity level: "simple", "medium", or "complex"
114    pub complexity: String,
115    /// Fibonacci effort estimate (1, 2, 3, 5, 8, 13, or 21). 0 means not provided.
116    pub estimate: u8,
117    /// Why this agent was selected
118    pub reason: String,
119    /// Specialized agent profile (skills, tools, constraints)
120    pub profile: AgentProfile,
121    /// Selected skill IDs from the catalog
122    pub selected_skills: Vec<String>,
123    /// Optional warning about routing decision
124    pub warning: Option<String>,
125}
126
127/// Specialized agent profile for a task.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
129pub struct AgentProfile {
130    /// Role name, e.g., "backend specialist"
131    pub role: String,
132    /// Focus skills for this task
133    pub skills: Vec<String>,
134    /// Tools allowed for this task
135    pub tools: Vec<String>,
136    /// Constraints for this task
137    pub constraints: Vec<String>,
138}
139
140impl AgentProfile {
141    /// Build the default "general" profile from router config.
142    pub fn default_from_config(config: &RouterConfig) -> Self {
143        Self {
144            role: "general".to_string(),
145            skills: vec![],
146            tools: config.allowed_tools.clone(),
147            constraints: vec![],
148        }
149    }
150}
151
152impl RouteResult {
153    /// Build a standard route result from the three task-specific values.
154    ///
155    /// Fills `profile`, `selected_skills`, and `warning` with router-config
156    /// defaults so call sites only need to supply what varies per task.
157    pub fn from_config(
158        agent: String,
159        complexity: String,
160        reason: String,
161        config: &RouterConfig,
162        task_id: &str,
163    ) -> Self {
164        let model = config.model_for_complexity(&agent, &complexity, task_id);
165        Self {
166            agent,
167            model,
168            complexity,
169            estimate: 0,
170            reason,
171            profile: AgentProfile::default_from_config(config),
172            selected_skills: config.default_skills.clone(),
173            warning: None,
174        }
175    }
176}
177
178/// An opaque, cheaply-cloneable handle to the LLM routing subsystem.
179///
180/// Obtain one via [`Router::llm_router_handle`].  Holding this handle does
181/// **not** require holding the router's `RwLock`, so async operations can
182/// safely be called without starving write-lock waiters.
183pub struct LlmRouterHandle(std::sync::Arc<LlmRouter>);
184
185impl LlmRouterHandle {
186    /// Invalidate the skills catalog cache so the next routing call reloads
187    /// from disk.  Safe to await without holding any `RwLock` on `Router`.
188    pub async fn invalidate_skills_catalog(&self) {
189        self.0.invalidate_skills_catalog().await;
190    }
191}
192
193/// The agent router.
194pub struct Router {
195    /// Router configuration
196    pub config: RouterConfig,
197    /// Available agents discovered at runtime
198    pub available_agents: Vec<String>,
199    /// Per-agent rate limit weights (used when weighted_round_robin is enabled)
200    pub weights: AgentWeights,
201    /// LLM routing subsystem (wrapped in Arc so callers can clone a handle
202    /// without holding the router RwLock across async operations)
203    llm_router: std::sync::Arc<LlmRouter>,
204    /// Ollama router for local routing (contains reusable reqwest::Client)
205    ollama_router: Option<ollama::OllamaRouter>,
206    /// Round-robin index for task routing
207    pub(crate) rr_index: usize,
208    /// Last agent routed to (for distribution tracking)
209    pub(crate) last_agent: Option<String>,
210    /// Round-robin index for review agent selection
211    pub(crate) review_rr_index: usize,
212    /// Expanded pool of (agent, model) pairs for router LLM round-robin.
213    /// `opencode:free` entries are expanded at construction time.
214    pub(crate) router_pool: Vec<(String, String)>,
215    /// Current round-robin index into router_pool
216    pub(crate) pool_index: usize,
217}
218
219impl Router {
220    /// Create a new router with the given configuration.
221    pub fn new(config: RouterConfig) -> Self {
222        let available_agents = Self::discover_agents(&config.agents);
223        let mut weights = AgentWeights {
224            base_weights: config.weights.clone(),
225            ..Default::default()
226        };
227        weights.ensure_agents_with_weights(&available_agents, &config.weights);
228        if !config.weights.is_empty() {
229            tracing::info!(
230                weights = ?config.weights,
231                "configured agent routing weights"
232            );
233        }
234        let router_pool = Self::expand_pool(&config);
235        tracing::info!(
236            pool = ?router_pool,
237            fallback = %config.effective_fallback(),
238            "router LLM pool initialized"
239        );
240        // Prime the RouterConfig free-model cache at startup (sync context) so
241        // that async callers hitting expanded_model_pool() later return
242        // instantly from cache instead of blocking a Tokio worker thread.
243        crate::engine::runner::agents::opencode::prime_free_model_cache();
244        let ollama_router = if config.mode == "local" {
245            Some(ollama::OllamaRouter::new(&config))
246        } else {
247            None
248        };
249        Self {
250            config,
251            available_agents,
252            weights,
253            llm_router: std::sync::Arc::new(LlmRouter::new()),
254            ollama_router,
255            rr_index: 0,
256            last_agent: None,
257            review_rr_index: 0,
258            router_pool,
259            pool_index: 0,
260        }
261    }
262
263    /// Create a router with default configuration loaded from files.
264    pub fn from_config() -> Self {
265        Self::new(RouterConfig::from_config())
266    }
267
268    /// Create a router with a forced agent list, bypassing CLI discovery.
269    /// Only for use in unit tests.
270    #[cfg(test)]
271    pub(crate) fn new_for_test(config: RouterConfig, available_agents: Vec<String>) -> Self {
272        let mut weights = AgentWeights {
273            base_weights: config.weights.clone(),
274            ..Default::default()
275        };
276        weights.ensure_agents(&available_agents);
277        let router_pool = Self::expand_pool(&config);
278        Self {
279            config,
280            available_agents,
281            weights,
282            llm_router: std::sync::Arc::new(LlmRouter::new()),
283            ollama_router: None,
284            rr_index: 0,
285            last_agent: None,
286            review_rr_index: 0,
287            router_pool,
288            pool_index: 0,
289        }
290    }
291
292    /// Return a cloned handle to the LLM router subsystem.
293    ///
294    /// Callers that need to invoke async methods on the underlying `LlmRouter`
295    /// should clone this handle *before* releasing any `RwLock` read guard on
296    /// `Router`, then call the async method after the guard is dropped.  This
297    /// prevents holding the read lock across await points (which starves
298    /// write-lock waiters due to Tokio's write-preferring `RwLock`).
299    pub fn llm_router_handle(&self) -> LlmRouterHandle {
300        LlmRouterHandle(self.llm_router.clone())
301    }
302
303    fn advance_pool_index_after_attempt(&mut self, idx: usize, pool_len: usize) {
304        debug_assert!(pool_len > 0, "router pool must not be empty");
305        self.pool_index = (idx + 1) % pool_len;
306    }
307
308    /// Discover available agent CLIs in PATH.
309    /// Checks all agents from the configured list.
310    fn discover_agents(configured_agents: &[String]) -> Vec<String> {
311        let mut agents = Vec::new();
312        for agent in configured_agents {
313            if crate::cmd_cache::command_exists(agent) {
314                agents.push(agent.to_string());
315            }
316        }
317        agents
318    }
319
320    /// Expand the router LLM pool, resolving `opencode:free` to discovered free models.
321    ///
322    /// - Entries other than `opencode:free` are parsed as `agent:model` and added as-is.
323    /// - `opencode:free` is expanded by running `opencode models | grep free` at startup.
324    /// - If `opencode` is not installed or the command fails, the entry is skipped.
325    /// - If the expanded pool is empty, falls back to a single entry from `effective_fallback()`.
326    fn expand_pool(config: &RouterConfig) -> Vec<(String, String)> {
327        let raw_pool = config.effective_pool();
328        let mut expanded: Vec<(String, String)> = Vec::new();
329
330        for entry in &raw_pool {
331            if entry == "opencode:free" {
332                let free_models = Self::discover_free_opencode_models();
333                if free_models.is_empty() {
334                    tracing::debug!("opencode:free expanded to nothing — skipping");
335                } else {
336                    tracing::debug!(models = ?free_models, "opencode:free expanded");
337                    for model in free_models {
338                        expanded.push(("opencode".to_string(), model));
339                    }
340                }
341            } else {
342                let (agent, model) = parse_pool_entry(entry);
343                expanded.push((agent, model));
344            }
345        }
346
347        if expanded.is_empty() {
348            // All entries were opencode:free and opencode isn't installed — use fallback
349            let (agent, model) = parse_pool_entry(&config.effective_fallback());
350            tracing::debug!(
351                agent = %agent, model = %model,
352                "pool empty after expansion, using fallback as sole pool entry"
353            );
354            expanded.push((agent, model));
355        }
356
357        expanded
358    }
359
360    /// Async version of expand_pool which may query the opencode CLI without
361    /// blocking the Tokio runtime.
362    async fn expand_pool_async(config: &RouterConfig) -> Vec<(String, String)> {
363        let raw_pool = config.effective_pool();
364        let mut expanded: Vec<(String, String)> = Vec::new();
365
366        for entry in &raw_pool {
367            if entry == "opencode:free" {
368                let free_models = Self::discover_free_opencode_models_async().await;
369                if free_models.is_empty() {
370                    tracing::debug!("opencode:free expanded to nothing — skipping (async)");
371                } else {
372                    tracing::debug!(models = ?free_models, "opencode:free expanded (async)");
373                    for model in free_models {
374                        expanded.push(("opencode".to_string(), model));
375                    }
376                }
377            } else {
378                let (agent, model) = parse_pool_entry(entry);
379                expanded.push((agent, model));
380            }
381        }
382
383        if expanded.is_empty() {
384            // All entries were opencode:free and opencode isn't installed — use fallback
385            let (agent, model) = parse_pool_entry(&config.effective_fallback());
386            tracing::debug!(
387                agent = %agent, model = %model,
388                "pool empty after async expansion, using fallback as sole pool entry"
389            );
390            expanded.push((agent, model));
391        }
392
393        expanded
394    }
395
396    /// Async reload that uses async pool expansion to avoid blocking the runtime
397    /// when discovering opencode free models.
398    pub async fn reload_async(&mut self) {
399        let new_config = RouterConfig::from_config();
400        let new_agents = Self::discover_agents(&new_config.agents);
401        let new_pool = Self::expand_pool_async(&new_config).await;
402        tracing::info!(
403            mode = %new_config.mode,
404            agents = ?new_agents,
405            fallback = %new_config.fallback_executor,
406            weighted_rr = new_config.weighted_round_robin,
407            pool = ?new_pool,
408            "router reloaded (async)"
409        );
410        self.config = new_config;
411        self.available_agents = new_agents.clone();
412        // Ensure new agents have weight entries (preserves existing weights)
413        self.weights.ensure_agents(&new_agents);
414        self.router_pool = new_pool;
415        self.pool_index = 0;
416        // Reinitialize Ollama router if mode changed to local
417        self.ollama_router = if self.config.mode == "local" {
418            Some(ollama::OllamaRouter::new(&self.config))
419        } else {
420            None
421        };
422    }
423
424    fn discover_free_opencode_models() -> Vec<String> {
425        crate::engine::runner::agents::opencode::discover_free_opencode_models()
426    }
427
428    async fn discover_free_opencode_models_async() -> Vec<String> {
429        tokio::task::spawn_blocking(Self::discover_free_opencode_models)
430            .await
431            .unwrap_or_default()
432    }
433
434    /// Run the pre-emptive health check, refreshing the degraded-agent set.
435    ///
436    /// Queries the `rate_limits` table for recent events and combines with
437    /// cooldown state to mark agents as degraded before routing attempts them.
438    pub async fn refresh_health(&self, store: &std::sync::Arc<TaskStore>) {
439        // KV is the source of truth for cooldowns. Reconcile the in-memory map
440        // from KV every tick so external mutations (e.g. `orch cooldown clear`
441        // from a separate CLI process) actually take effect on the running
442        // service. Without this, the engine keeps stale entries until they
443        // expire on their own, producing the "all agents cooled" log with
444        // `orch cooldown list` simultaneously reporting "No active cooldowns".
445        sync_from_kv(store).await;
446
447        let agents = self.available_agents.clone();
448        let model_checker = |agent: &str| -> bool { self.agent_has_any_available_model(agent) };
449        refresh_degraded_agents(
450            store,
451            &agents,
452            &model_checker,
453            config::health_check_window_hours(),
454            config::degraded_rate_limit_threshold(),
455        )
456        .await;
457    }
458
459    /// Check if an agent is available.
460    pub fn is_agent_available(&self, agent: &str) -> bool {
461        self.available_agents.contains(&agent.to_string())
462    }
463
464    /// True if the agent has at least one uncooled model across any complexity
465    /// tier. Used where the eventual complexity isn't known yet (e.g. before
466    /// LLM classification), so we can't check a single tier.
467    fn agent_has_any_available_model(&self, agent: &str) -> bool {
468        ["simple", "medium", "complex", "review"]
469            .iter()
470            .any(|comp| self.config.has_available_model_for_complexity(agent, comp))
471    }
472
473    /// True if the agent has an available (uncooled) model for the `medium`
474    /// and `complex` tiers — the tiers real coding tasks are classified into
475    /// by the LLM classifier. Used to pre-filter the candidate list handed to
476    /// that classifier so an agent whose healthy tiers are only `simple` isn't
477    /// offered: the classifier can't be constrained to `simple`, so offering
478    /// such an agent produces a near-certain wasted classification call whenever
479    /// it picks `medium`/`complex` (issue #3521). `review` is not a candidate
480    /// outcome of initial classification and is deliberately excluded.
481    fn agent_has_available_model_for_coding_tiers(&self, agent: &str) -> bool {
482        ["medium", "complex"]
483            .iter()
484            .all(|comp| self.config.has_available_model_for_complexity(agent, comp))
485    }
486
487    fn agent_is_routable(&self, agent: &str, complexity: &str) -> bool {
488        if !self.is_agent_available(agent) {
489            return false;
490        }
491        if is_agent_in_cooldown(agent) {
492            tracing::debug!(agent, "agent skipped: in cooldown");
493            return false;
494        }
495        if is_agent_degraded(agent) {
496            tracing::debug!(agent, "agent skipped: degraded (pre-emptive health check)");
497            return false;
498        }
499        // If weighted routing is used, consider the agent degraded when its
500        // weight has decayed below the configured threshold. This avoids
501        // proactively routing to agents that recently hit many rate limits.
502        if self.config.weighted_round_robin {
503            let weight = self.weights.get_weight(agent);
504            if weight < self.config.skip_limited_threshold {
505                tracing::debug!(
506                    agent,
507                    weight,
508                    "agent considered degraded by weight threshold, skipping"
509                );
510                return false;
511            }
512        }
513        if !self
514            .config
515            .has_available_model_for_complexity(agent, complexity)
516        {
517            tracing::debug!(agent, complexity, "agent skipped: no available model");
518            return false;
519        }
520        true
521    }
522
523    fn available_agents_for_complexity(&self, complexity: &str) -> Vec<String> {
524        self.available_agents
525            .iter()
526            .filter(|agent| self.agent_is_routable(agent, complexity))
527            .cloned()
528            .collect()
529    }
530
531    /// Count of healthy (routable) agents for a given complexity.
532    /// Returns the number of agents that are available, not in cooldown, and have an available model.
533    pub fn healthy_agent_count(&self, complexity: &str) -> usize {
534        self.available_agents_for_complexity(complexity).len()
535    }
536
537    /// Check if the system is in degraded mode (fewer than threshold healthy agents).
538    /// Uses "simple" complexity as the baseline since it has the widest model support.
539    pub fn is_degraded(&self, threshold: usize) -> bool {
540        self.healthy_agent_count("simple") < threshold
541    }
542
543    pub fn earliest_cooldown_until(&self, complexity: Option<&str>) -> Option<i64> {
544        let mut earliest: Option<i64> = None;
545
546        for agent in &self.available_agents {
547            if let Some(until) = cooldown_until(agent) {
548                earliest = Some(earliest.map_or(until, |current| current.min(until)));
549            }
550
551            if let Some(comp) = complexity {
552                if let Some(pool) = self.config.model_pool_for_complexity(agent, comp) {
553                    for model in pool {
554                        let key = format!("{agent}:{model}");
555                        if let Some(until) = cooldown_until(&key) {
556                            earliest = Some(earliest.map_or(until, |current| current.min(until)));
557                        }
558                    }
559                }
560            }
561        }
562
563        earliest
564    }
565
566    /// Find the earliest cooldown expiration among the router LLM pool entries.
567    ///
568    /// Returns `Some(earliest_ts)` if at least one pool model is on cooldown,
569    /// or `None` if no pool model has a cooldown.
570    fn earliest_pool_cooldown(&self) -> Option<i64> {
571        let mut earliest: Option<i64> = None;
572        for (agent, model) in &self.router_pool {
573            let key = format!("{agent}:{model}");
574            if let Some(until) = cooldown_until(&key) {
575                earliest = Some(earliest.map_or(until, |current| current.min(until)));
576            }
577        }
578        earliest
579    }
580
581    async fn wait_for_cooldown(&self, complexity: Option<&str>) -> anyhow::Result<()> {
582        // If any cooldowns are present, return an error immediately so the
583        // caller (the tick loop) can skip this task and retry on the next tick.
584        // Sleeping inside the router blocks the entire tick loop for the
585        // cooldown duration which starves other tasks. The engine tick already
586        // retries every `tick_interval` (default 10s), so allow the tick loop
587        // to drive retries instead of sleeping here.
588        let now = chrono::Utc::now().timestamp();
589        let Some(earliest) = self.earliest_cooldown_until(complexity) else {
590            // All agents are degraded but none have a timed cooldown entry
591            // (e.g. over-threshold rate limiting without explicit cooldown duration).
592            // Log at warn and return an error so the tick skips this task and retries after backoff.
593            tracing::warn!(
594        scope = complexity.unwrap_or("all agents"),
595        "all agents degraded (no cooldown timestamp available) — skipping task, will retry after backoff"
596    );
597            // Return an error similar to the cooldown case to trigger backoff in the tick loop
598            return Err(AllAgentsCooledError::new(
599                complexity.unwrap_or("all agents").to_string(),
600                None,
601            )
602            .into());
603        };
604
605        let remaining = earliest.saturating_sub(now);
606        if remaining > 0 {
607            let scope_str = complexity.unwrap_or("all agents").to_string();
608            tracing::warn!(
609                remaining_secs = remaining,
610                scope = %scope_str,
611                "all agents/models cooled — routing is unavailable until cooldown expires; failing fast to let tick retry"
612            );
613            // Return a domain error indicating all candidates are cooled so the
614            // caller can handle it (tick will skip this task and retry later).
615            return Err(AllAgentsCooledError::new(scope_str, Some(remaining as u64)).into());
616        }
617
618        Ok(())
619    }
620
621    /// Get the first available agent.
622    /// Pick next agent via round-robin (for review or other non-task routing).
623    /// Pick the next review agent, optionally excluding one (e.g. the task's original agent).
624    /// Falls back to the first candidate whenever every candidate is excluded (#3645).
625    pub fn next_round_robin_agent(&mut self, exclude: &[&str], complexity: &str) -> Option<String> {
626        // Use available_agents_for_complexity which already filters using agent_is_routable,
627        // eliminating duplicate cooldown/degraded/model checks.
628        let candidates = self.available_agents_for_complexity(complexity);
629        if candidates.is_empty() {
630            return None;
631        }
632        let idx = self.review_rr_index;
633        let n = candidates.len();
634
635        // Try to find an agent that isn't excluded
636        let agent = (0..n)
637            .map(|offset| &candidates[(idx + offset) % n])
638            .find(|a| !exclude.contains(&a.as_str()))
639            .cloned()
640            // Fallback: use the first available candidate (even if excluded)
641            .or_else(|| candidates.first().cloned())?;
642
643        self.review_rr_index = (idx + 1) % candidates.len().max(1);
644        Some(agent)
645    }
646
647    /// If the selected agent matches `no_code_last_agent` for this task, filter it
648    /// out of the candidates and return the filtered list. Used to prevent the
649    /// same agent from being selected twice in a row after a no-code failure (#2410).
650    async fn skip_no_code_agent(
651        &self,
652        store: &std::sync::Arc<TaskStore>,
653        repo: &str,
654        task_id: &str,
655        candidates: &[String],
656    ) -> Option<Vec<String>> {
657        let no_code_agent = get_task_field_direct(store, repo, task_id, "no_code_last_agent")
658            .await
659            .unwrap_or_else(|| {
660                tracing::warn!(
661                    task_id,
662                    "no_code_last_agent DB read returned None — loop detection may be impaired"
663                );
664                String::new()
665            });
666        if no_code_agent.is_empty() || !candidates.iter().any(|a| a == &no_code_agent) {
667            return None;
668        }
669        let filtered: Vec<String> = candidates
670            .iter()
671            .filter(|a| a.as_str() != no_code_agent)
672            .cloned()
673            .collect();
674        if filtered.is_empty() {
675            tracing::warn!(
676                task_id,
677                no_code_agent = %no_code_agent,
678                "all available agents are the no-code agent — cannot skip"
679            );
680            return None;
681        }
682        tracing::info!(
683            task_id,
684            skipped = %no_code_agent,
685            remaining = ?filtered,
686            "skipping no-code agent, routing to alternative"
687        );
688        Some(filtered)
689    }
690
691    /// Route a task to the best agent.
692    ///
693    /// Routing logic (in priority order):
694    /// 1. Check for `agent:*` label — use that agent directly
695    /// 2. If weighted_round_robin enabled, select by capacity-weighted probability
696    /// 3. If round_robin mode, cycle through agents (stateful)
697    /// 4. Call LLM classifier for intelligent routing
698    /// 5. After max_route_attempts LLM failures, fall back to round-robin
699    pub async fn route(
700        &mut self,
701        task: &ExternalTask,
702        store: &std::sync::Arc<TaskStore>,
703        repo: &str,
704    ) -> anyhow::Result<RouteResult> {
705        // NOTE: refresh_health() is called once per tick in tick_route_tasks()
706        // before the routing loop, not per-task. Do not re-add it here.
707
708        // Hold the task before any classification, label override, or fallback.
709        // A waiting task must not increment route_attempts.
710        match store.resolve_id_by_external(repo, &task.id.0).await {
711            Ok(Some(id)) => match store.block_claim_if_waiting(id).await {
712                Ok(Some(blockers)) => {
713                    return Err(crate::store::WaitingOnDependency { blockers }.into());
714                }
715                Ok(None) => {}
716                Err(err) => return Err(err),
717            },
718            Ok(None) => {}
719            Err(err) => return Err(err),
720        }
721
722        loop {
723            // 1. Check store agent field first (set by failover, authoritative over labels)
724            // Use a lightweight lookup to avoid deserializing the full Task row when
725            // only the `agent` column is needed. This avoids two full-row loads
726            // per routing pass (resolve_task_id + get) and significantly reduces
727            // CPU work on busy instances.
728            let store_agent = match store.get_agent_by_external_id(repo, &task.id.0).await {
729                Ok(Some(agent)) => {
730                    // Router enforces that the agent string matches a configured agent
731                    if !agent.is_empty() && self.config.agents.iter().any(|cfg| cfg == &agent) {
732                        Some(agent)
733                    } else {
734                        None
735                    }
736                }
737                Ok(None) => None,
738                Err(e) => {
739                    tracing::warn!(
740                        repo,
741                        task_id = %task.id.0,
742                        err = %e,
743                        "failed to fetch stored agent, falling back to label/LLM routing"
744                    );
745                    None
746                }
747            };
748
749            // 2. Fall back to explicit agent label
750            let resolved_agent = store_agent.or_else(|| {
751                strategies::extract_agent_from_labels(&self.config.agents, &task.labels)
752            });
753
754            if let Some(agent) = resolved_agent {
755                let complexity = strategies::extract_complexity_from_labels(&task.labels);
756                // If agent appears routable (not in agent-wide cooldown and has
757                // available models or no model_map configured), check whether
758                // we actually have a concrete model to dispatch. If the
759                // resolved label maps to no model (None), we fall through to
760                // the standard routing logic so the router LLM can pick a
761                // proper agent/model, instead of dispatching with an empty
762                // model which causes some agents (e.g. opencode) to exit
763                // silently.
764                if self.agent_is_routable(&agent, &complexity) {
765                    let model = self
766                        .config
767                        .model_for_complexity(&agent, &complexity, &task.id.0);
768
769                    // Only dispatch label overrides when we have a concrete
770                    // model to send to the executor. Dispatching with an
771                    // empty model led to silent exits for some agents
772                    // (notably opencode). If no concrete model is available
773                    // for the labeled agent, fall through to the standard
774                    // routing logic so the LLM or round-robin can pick an
775                    // appropriate agent/model.
776                    if model.is_some() {
777                        tracing::debug!(
778                            task_id = %task.id.0,
779                            agent = %agent,
780                            complexity = %complexity,
781                            "routed via label"
782                        );
783                        let mut result = RouteResult::from_config(
784                            agent.clone(),
785                            complexity.clone(),
786                            format!("label agent:{agent}"),
787                            &self.config,
788                            &task.id.0,
789                        );
790                        // Label routing uses agent-specific role and the
791                        // resolved model (may differ from from_config's default).
792                        result.profile.role = format!("{} specialist", agent);
793                        result.model = model.clone();
794                        self.log_route_activity(store, repo, &task.id.0, &result, None)
795                            .await;
796                        return Ok(result);
797                    } else {
798                        // No concrete model found for the labeled agent — do
799                        // not honor the label override. Fall through to the
800                        // standard routing logic so another agent or the LLM
801                        // can be chosen with a valid model.
802                        tracing::warn!(task_id = %task.id.0, agent = %agent, "label agent has no available model — falling through to LLM routing");
803                    }
804                }
805
806                let candidates = self.available_agents_for_complexity(&complexity);
807                if candidates.is_empty() {
808                    self.wait_for_cooldown(Some(&complexity)).await?;
809                    continue;
810                }
811                // Label target is cooled or lacked model; fall through to standard routing.
812            }
813
814            let complexity = strategies::extract_complexity_from_labels(&task.labels);
815
816            // Check if any agents have available models for this complexity
817            let mut candidates = self.available_agents_for_complexity(&complexity);
818            if candidates.is_empty() {
819                self.wait_for_cooldown(Some(&complexity)).await?;
820                continue;
821            }
822
823            // Skip the no-code agent if one is recorded for this task (#2410).
824            // If all available agents are filtered out, we fall through to
825            // LLM routing with no constraints (LLM will pick the same agent but
826            // the response_handler will block it immediately via same-agent check).
827            if let Some(filtered) = self
828                .skip_no_code_agent(store, repo, &task.id.0, &candidates)
829                .await
830            {
831                candidates = filtered;
832            }
833
834            // 2. Weighted round-robin — capacity-based selection
835            if self.config.weighted_round_robin {
836                let result = strategies::route_via_weighted_round_robin(
837                    &candidates,
838                    &self.weights,
839                    &self.config,
840                    task,
841                    &mut self.last_agent,
842                )?;
843                self.log_route_activity(store, repo, &task.id.0, &result, None)
844                    .await;
845                return Ok(result);
846            }
847
848            // 3. Round-robin mode — use stateful round-robin
849            if self.config.mode == "round_robin" {
850                tracing::debug!(task_id = %task.id.0, "routing via round-robin mode");
851                let result = strategies::route_via_round_robin_stateful(
852                    &candidates,
853                    &self.config,
854                    task,
855                    &mut self.rr_index,
856                    &mut self.last_agent,
857                )?;
858                self.log_route_activity(store, repo, &task.id.0, &result, None)
859                    .await;
860                return Ok(result);
861            }
862
863            // 4. Local Ollama routing
864            if self.config.mode == "local" {
865                tracing::debug!(task_id = %task.id.0, ollama_url = %self.config.ollama_url, "routing via local Ollama");
866                let ollama_router = self
867                    .ollama_router
868                    .as_ref()
869                    .expect("ollama_router must be initialized in Router::new when mode=local");
870                match ollama_router.route(task, &self.config).await {
871                    Ok(result) => {
872                        self.log_route_activity(store, repo, &task.id.0, &result, None)
873                            .await;
874                        return Ok(result);
875                    }
876                    Err(e) => {
877                        tracing::warn!(
878                            task_id = %task.id.0,
879                            error = %e,
880                            "Ollama routing failed, falling back to round-robin"
881                        );
882                        let result = strategies::route_via_round_robin_stateful(
883                            &candidates,
884                            &self.config,
885                            task,
886                            &mut self.rr_index,
887                            &mut self.last_agent,
888                        )?;
889                        self.log_route_activity(store, repo, &task.id.0, &result, None)
890                            .await;
891                        return Ok(result);
892                    }
893                }
894            }
895
896            // 5. LLM-based routing with retry tracking
897            let route_attempts = self.get_route_attempts(&task.id.0, store, repo).await;
898
899            if route_attempts >= self.config.max_route_attempts {
900                tracing::warn!(
901                    task_id = %task.id.0,
902                    attempts = route_attempts,
903                    max = self.config.max_route_attempts,
904                    "max LLM route attempts reached, falling back to round-robin"
905                );
906                let candidates = self.available_agents_for_complexity(&complexity);
907                if candidates.is_empty() {
908                    self.wait_for_cooldown(Some(&complexity)).await?;
909                    continue;
910                }
911                let result = strategies::route_via_round_robin_stateful(
912                    &candidates,
913                    &self.config,
914                    task,
915                    &mut self.rr_index,
916                    &mut self.last_agent,
917                )?;
918                self.log_route_activity(store, repo, &task.id.0, &result, None)
919                    .await;
920                return Ok(result);
921            }
922
923            // Log routing start (before await)
924            tracing::debug!(task_id = %task.id.0, "starting LLM routing");
925
926            match self.route_with_llm(task, repo).await {
927                Ok(result) => {
928                    let candidates = self.available_agents_for_complexity(&result.complexity);
929                    if candidates.is_empty() {
930                        self.wait_for_cooldown(Some(&result.complexity)).await?;
931                        continue;
932                    }
933
934                    // If the LLM selected the no-code agent, filter it out and
935                    // pick an alternative (#2410). Re-use the existing fallback logic.
936                    let no_code_agent =
937                         get_task_field_direct(store, repo, &task.id.0, "no_code_last_agent")
938                             .await
939                             .unwrap_or_else(|| {
940                                 tracing::warn!(task_id = %task.id.0, "no_code_last_agent DB read returned None — loop detection may be impaired");
941                                 String::new()
942                             });
943                    if result.agent == no_code_agent {
944                        // Pick the first agent that isn't the no-code agent.
945                        let fallback_agent = candidates
946                            .iter()
947                            .find(|a| a.as_str() != no_code_agent)
948                            .cloned()
949                            .unwrap_or_else(|| candidates[0].clone());
950                        if fallback_agent.as_str() == no_code_agent {
951                            tracing::warn!(
952                                task_id = %task.id.0,
953                                no_code_agent = %no_code_agent,
954                                complexity = %result.complexity,
955                                "all available agents are the no-code agent — waiting for another agent"
956                            );
957                            self.wait_for_cooldown(Some(&result.complexity)).await?;
958                            continue;
959                        }
960                        tracing::info!(
961                            task_id = %task.id.0,
962                            llm_selected = %result.agent,
963                            skipped = %no_code_agent,
964                            fallback = %fallback_agent,
965                            "LLM selected the no-code agent — routing to alternative"
966                        );
967                        let fallback_model = self.config.model_for_complexity(
968                            &fallback_agent,
969                            &result.complexity,
970                            &task.id.0,
971                        );
972
973                        self.set_route_attempts(&task.id.0, 0, store, repo).await;
974                        let result = RouteResult {
975                            agent: fallback_agent.clone(),
976                            model: fallback_model,
977                            complexity: result.complexity.clone(),
978                            estimate: result.estimate,
979                            reason: format!(
980                                "LLM selected no-code agent {}; routed to {}",
981                                result.agent, fallback_agent
982                            ),
983                            profile: result.profile.clone(),
984                            selected_skills: result.selected_skills.clone(),
985                            warning: Some(format!(
986                                "LLM selected no-code agent {}; used fallback {}",
987                                result.agent, fallback_agent
988                            )),
989                        };
990                        self.log_route_activity(store, repo, &task.id.0, &result, None)
991                            .await;
992                        return Ok(result);
993                    }
994
995                    let model_cooled = result
996                        .model
997                        .as_deref()
998                        .is_some_and(|model| is_model_in_cooldown(&result.agent, model));
999                    if !candidates.contains(&result.agent) || model_cooled {
1000                        let fallback_agent = candidates
1001                            .iter()
1002                            .find(|agent| *agent != &result.agent)
1003                            .cloned()
1004                            .unwrap_or_else(|| candidates[0].clone());
1005                        let fallback_model = self.config.model_for_complexity(
1006                            &fallback_agent,
1007                            &result.complexity,
1008                            &task.id.0,
1009                        );
1010
1011                        // If the fallback model is None and the fallback agent is the
1012                        // same as the LLM-selected agent, it means all models for the
1013                        // only available agent are cooled. In that case, wait for
1014                        // cooldowns instead of returning a RouteResult with
1015                        // model: None which would be dispatched incorrectly.
1016                        if fallback_model.is_none() && fallback_agent == result.agent {
1017                            tracing::warn!(
1018                                task_id = %task.id.0,
1019                                agent = %result.agent,
1020                                complexity = %result.complexity,
1021                                "LLM-selected model(s) are cooled for the only available agent; waiting for cooldown"
1022                            );
1023                            self.wait_for_cooldown(Some(&result.complexity)).await?;
1024                            continue;
1025                        }
1026
1027                        // Distinguish logging and reasons when we actually switch
1028                        // agents vs when we keep the same agent but pick a
1029                        // different (non-cooled) model.
1030                        if fallback_agent == result.agent {
1031                            tracing::warn!(
1032                                task_id = %task.id.0,
1033                                agent = %result.agent,
1034                                complexity = %result.complexity,
1035                                "LLM-selected model was cooled; selecting an alternative model for the same agent"
1036                            );
1037                        } else {
1038                            tracing::warn!(
1039                                task_id = %task.id.0,
1040                                agent = %result.agent,
1041                                fallback = %fallback_agent,
1042                                complexity = %result.complexity,
1043                                "LLM selected cooled agent/model; rerouting to available agent"
1044                            );
1045                        }
1046
1047                        // Reset attempts on success
1048                        self.set_route_attempts(&task.id.0, 0, store, repo).await;
1049
1050                        let reason = if fallback_agent == result.agent {
1051                            format!(
1052                                "LLM selected cooled model; using same agent {} with different model",
1053                                fallback_agent
1054                            )
1055                        } else {
1056                            format!(
1057                                "LLM selected cooled agent/model; rerouted to {}",
1058                                fallback_agent
1059                            )
1060                        };
1061
1062                        let result = RouteResult {
1063                            agent: fallback_agent.clone(),
1064                            model: fallback_model,
1065                            complexity: result.complexity.clone(),
1066                            estimate: result.estimate,
1067                            reason: reason.clone(),
1068                            profile: result.profile.clone(),
1069                            selected_skills: result.selected_skills.clone(),
1070                            warning: Some(if fallback_agent == result.agent {
1071                                "LLM-selected model was cooled; selected alternative model for same agent"
1072                                    .to_string()
1073                            } else {
1074                                "LLM-selected agent/model was cooled; rerouted to available agent"
1075                                    .to_string()
1076                            }),
1077                        };
1078                        self.log_route_activity(store, repo, &task.id.0, &result, None)
1079                            .await;
1080                        return Ok(result);
1081                    }
1082
1083                    // Reset attempts on success
1084                    self.set_route_attempts(&task.id.0, 0, store, repo).await;
1085                    tracing::info!(
1086                        task_id = %task.id.0,
1087                        agent = %result.agent,
1088                        complexity = %result.complexity,
1089                        "routed via LLM"
1090                    );
1091                    self.log_route_activity(store, repo, &task.id.0, &result, None)
1092                        .await;
1093                    return Ok(result);
1094                }
1095                Err(e) => {
1096                    if let Some(err) = e.downcast_ref::<AllAgentsCooledError>() {
1097                        let scope = err.scope();
1098                        tracing::warn!(scope = %scope, "router cooldown gate tripped");
1099                        // "router pool" scope means all router LLM pool entries were pre-cooled.
1100                        // Pass None so wait_for_cooldown queries all agent+model cooldowns generically
1101                        // (no complexity-specific pool lookup, which would fail for "router pool").
1102                        let scope_opt = match scope {
1103                            "all agents" | "router pool" => None,
1104                            _ => Some(scope),
1105                        };
1106                        self.wait_for_cooldown(scope_opt).await?;
1107                        continue;
1108                    }
1109
1110                    let new_attempts = route_attempts + 1;
1111                    self.set_route_attempts(&task.id.0, new_attempts, store, repo)
1112                        .await;
1113                    tracing::warn!(
1114                        task_id = %task.id.0,
1115                        error = %e,
1116                        error_chain = ?e,
1117                        attempt = new_attempts,
1118                        max = self.config.max_route_attempts,
1119                        "LLM routing failed"
1120                    );
1121
1122                    if new_attempts >= self.config.max_route_attempts {
1123                        tracing::info!(
1124                            task_id = %task.id.0,
1125                            "falling back to round-robin after {} failed attempts",
1126                            new_attempts
1127                        );
1128                        let candidates = self.available_agents_for_complexity(&complexity);
1129                        if candidates.is_empty() {
1130                            self.wait_for_cooldown(Some(&complexity)).await?;
1131                            continue;
1132                        }
1133                        let result = strategies::route_via_round_robin_stateful(
1134                            &candidates,
1135                            &self.config,
1136                            task,
1137                            &mut self.rr_index,
1138                            &mut self.last_agent,
1139                        )?;
1140                        self.log_route_activity(store, repo, &task.id.0, &result, None)
1141                            .await;
1142                        return Ok(result);
1143                    }
1144
1145                    let candidates = self.available_agents_for_complexity(&complexity);
1146                    if candidates.is_empty() {
1147                        self.wait_for_cooldown(Some(&complexity)).await?;
1148                        continue;
1149                    }
1150                    let result = strategies::route_via_fallback(
1151                        &candidates,
1152                        &self.config,
1153                        task,
1154                        Some(&self.weights),
1155                    )?;
1156                    self.log_route_activity(store, repo, &task.id.0, &result, None)
1157                        .await;
1158                    return Ok(result);
1159                }
1160            }
1161        }
1162    }
1163
1164    /// Get the number of LLM routing attempts for a task from the store.
1165    async fn get_route_attempts(
1166        &self,
1167        task_id: &str,
1168        store: &std::sync::Arc<TaskStore>,
1169        repo: &str,
1170    ) -> u32 {
1171        match store.resolve_task_id(repo, task_id).await {
1172            Ok(Some(store_id)) => store
1173                .get(store_id)
1174                .await
1175                .map(|t| t.route_attempts as u32)
1176                .unwrap_or(0),
1177            _ => 0,
1178        }
1179    }
1180
1181    /// Set the number of LLM routing attempts for a task in the store.
1182    async fn set_route_attempts(
1183        &self,
1184        task_id: &str,
1185        attempts: u32,
1186        store: &std::sync::Arc<TaskStore>,
1187        repo: &str,
1188    ) {
1189        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1190            if let Err(e) = store
1191                .set_fields(store_id, &[("route_attempts", serde_json::json!(attempts))])
1192                .await
1193            {
1194                tracing::warn!(task_id, error = %e, "failed to store route_attempts");
1195            }
1196        }
1197    }
1198
1199    /// Log a route event to task activity timeline.
1200    async fn log_route_activity(
1201        &self,
1202        store: &std::sync::Arc<TaskStore>,
1203        repo: &str,
1204        task_id: &str,
1205        result: &RouteResult,
1206        from_agent: Option<&str>,
1207    ) {
1208        let details = serde_json::json!({
1209            "reason": result.reason,
1210            "complexity": result.complexity,
1211            "skills": result.selected_skills,
1212            "role": result.profile.role,
1213            "warning": result.warning,
1214        });
1215        let event_type = if from_agent.is_some() {
1216            "rerouted"
1217        } else {
1218            "routed"
1219        };
1220        store_log_activity(
1221            &Some(Arc::clone(store)),
1222            repo,
1223            task_id,
1224            event_type,
1225            None,
1226            Some("routed"),
1227            Some(&result.agent),
1228            result.model.as_deref(),
1229            Some(&details),
1230        )
1231        .await;
1232    }
1233
1234    /// Route using LLM classification with pool round-robin.
1235    ///
1236    /// Iterates through `router_pool` (skipping cooled entries) until one succeeds.
1237    /// On full exhaustion, tries the configured fallback. If the fallback also fails,
1238    /// returns the last error (the caller falls back to round-robin agent selection).
1239    async fn route_with_llm(
1240        &mut self,
1241        task: &ExternalTask,
1242        repo: &str,
1243    ) -> anyhow::Result<RouteResult> {
1244        // Filter out cooled and degraded agents, and agents without an available
1245        // model for the coding tiers (`medium`/`complex`) that real tasks are
1246        // classified into, so the LLM only sees candidates that could actually
1247        // be dispatched for whatever complexity it picks. An agent whose only
1248        // healthy tier is `simple` is excluded too: the classifier can't be
1249        // constrained to `simple`, so leaving such an agent in would waste the
1250        // call whenever it picks `medium`/`complex` (issue #3521).
1251        // Fall back to the full list if all agents are cooled/degraded.
1252        // Cloned to avoid borrow conflict with &mut self in the loop.
1253        let uncooled_agents: Vec<String> = self
1254            .available_agents
1255            .iter()
1256            .filter(|a| {
1257                !is_agent_in_cooldown(a)
1258                    && !is_agent_degraded(a)
1259                    && self.agent_has_available_model_for_coding_tiers(a)
1260            })
1261            .cloned()
1262            .collect();
1263        if uncooled_agents.is_empty() {
1264            return Err(AllAgentsCooledError::new("all agents".to_string(), None).into());
1265        }
1266        let llm_agents = uncooled_agents;
1267
1268        let pool = self.router_pool.clone();
1269        let n = pool.len();
1270        let start = self.pool_index;
1271        let mut last_err: Option<anyhow::Error> = None;
1272        let mut attempted_any = false;
1273
1274        // Try pool entries in round-robin order, skipping cooled ones
1275        for i in 0..n {
1276            let idx = (start + i) % n;
1277            let (agent, model) = &pool[idx];
1278            let model_str = model.as_str();
1279
1280            if crate::engine::runner::response::is_model_in_cooldown(agent, model_str) {
1281                tracing::debug!(agent, model = model_str, "pool entry on cooldown, skipping");
1282                continue;
1283            }
1284
1285            // At least one pool entry was available to attempt
1286            attempted_any = true;
1287
1288            let model_opt = if model_str.is_empty() {
1289                None
1290            } else {
1291                Some(model_str)
1292            };
1293
1294            match self
1295                .llm_router
1296                .route_with_llm_using(
1297                    task,
1298                    &llm_agents,
1299                    &self.config,
1300                    &mut self.last_agent,
1301                    repo,
1302                    agent,
1303                    model_opt,
1304                )
1305                .await
1306            {
1307                Ok(result) => {
1308                    // Advance index so the next call starts at the next pool entry
1309                    self.advance_pool_index_after_attempt(idx, n);
1310                    tracing::debug!(
1311                        agent,
1312                        model = model_str,
1313                        pool_idx = idx,
1314                        "router LLM pool entry succeeded"
1315                    );
1316                    record_agent_success(agent, model_str).await;
1317                    return Ok(result);
1318                }
1319                Err(e) => {
1320                    let err_msg = e.to_string();
1321                    let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1322                    if is_timeout {
1323                        // Advance pool index so the next tick starts at the next entry,
1324                        // then return immediately. Continuing to try further pool entries
1325                        // in the same tick would accumulate multiple timeout windows and
1326                        // stall the tick loop for minutes (issue #3187).
1327                        //
1328                        // Also record a standard cooldown, same as the non-timeout branch,
1329                        // so a chronically-timing-out entry is skipped by is_model_in_cooldown
1330                        // on future ticks instead of burning the full timeout window again
1331                        // every time round-robin reaches it (issue #3422).
1332                        self.advance_pool_index_after_attempt(idx, n);
1333                        tracing::warn!(
1334                            agent,
1335                            model = model_str,
1336                            next_pool_idx = self.pool_index,
1337                            "router LLM pool entry timed out — recording cooldown, will retry next tick"
1338                        );
1339                        crate::engine::runner::response::record_model_failure(agent, model_str)
1340                            .await;
1341                        return Err(e);
1342                    } else {
1343                        tracing::warn!(
1344                            agent,
1345                            model = model_str,
1346                            error = %e,
1347                            "pool entry failed, recording cooldown and trying next"
1348                        );
1349                        crate::engine::runner::response::record_model_failure(agent, model_str)
1350                            .await;
1351                    }
1352                    last_err = Some(e);
1353                    self.advance_pool_index_after_attempt(idx, n);
1354                }
1355            }
1356        }
1357
1358        // If every pool entry was pre-cooled (nothing was attempted), do not
1359        // immediately exhaust the pool again — fail fast and let the tick loop
1360        // retry on the next cycle instead of cascading failures.
1361        // However, we should still try the fallback if configured.
1362        if !attempted_any {
1363            let now = chrono::Utc::now().timestamp();
1364            let earliest = self.earliest_pool_cooldown();
1365            if let Some(until) = earliest {
1366                // Pool entries exist but are all cooled - we'll try fallback below
1367                let remaining = until.saturating_sub(now);
1368                tracing::warn!(
1369                    remaining_secs = remaining,
1370                    pool = ?pool,
1371                    "all router LLM pool entries on cooldown — will try fallback"
1372                );
1373                // Continue to fallback logic instead of returning early
1374            } else {
1375                // No pool cooldowns found either — this means the pool was empty
1376                // or every entry was filtered out for some other reason.
1377                return Err(last_err
1378                    .unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")));
1379            }
1380        }
1381
1382        // All pool entries failed or were cooled — try the configured fallback
1383        let fallback = self.config.effective_fallback();
1384        let (fb_agent, fb_model) = parse_pool_entry(&fallback);
1385        let fb_model_str = fb_model.as_str();
1386
1387        // Only try fallback if it wasn't already in the pool (avoid double-try)
1388        let fallback_already_tried = pool.iter().any(|(a, m)| a == &fb_agent && m == &fb_model);
1389
1390        if !fallback_already_tried
1391            && !crate::engine::runner::response::is_model_in_cooldown(&fb_agent, fb_model_str)
1392        {
1393            tracing::info!(
1394                agent = %fb_agent,
1395                model = %fb_model_str,
1396                "all pool entries exhausted — trying fallback router LLM"
1397            );
1398            let fb_model_opt = if fb_model_str.is_empty() {
1399                None
1400            } else {
1401                Some(fb_model_str)
1402            };
1403            match self
1404                .llm_router
1405                .route_with_llm_using(
1406                    task,
1407                    &llm_agents,
1408                    &self.config,
1409                    &mut self.last_agent,
1410                    repo,
1411                    &fb_agent,
1412                    fb_model_opt,
1413                )
1414                .await
1415            {
1416                Ok(result) => {
1417                    record_agent_success(&fb_agent, fb_model_str).await;
1418                    return Ok(result);
1419                }
1420                Err(e) => {
1421                    let err_msg = e.to_string();
1422                    let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1423                    if is_timeout {
1424                        // Cool down on timeout like pool entries (#3422), else a
1425                        // chronically-timing-out fallback burns the full timeout window every tick (#3616)
1426                        crate::engine::runner::response::record_model_failure(
1427                            &fb_agent,
1428                            fb_model_str,
1429                        )
1430                        .await;
1431                        tracing::warn!(
1432                            agent = %fb_agent,
1433                            model = %fb_model_str,
1434                            "fallback router LLM timed out — returning immediately to unblock tick loop"
1435                        );
1436                        return Err(e);
1437                    } else {
1438                        tracing::warn!(
1439                            agent = %fb_agent,
1440                            model = %fb_model_str,
1441                            error = %e,
1442                            "fallback router LLM also failed"
1443                        );
1444                        crate::engine::runner::response::record_model_failure(
1445                            &fb_agent,
1446                            fb_model_str,
1447                        )
1448                        .await;
1449                    }
1450                    last_err = Some(e);
1451                }
1452            }
1453        }
1454
1455        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")))
1456    }
1457
1458    /// Record a rate limit event for an agent, reducing its routing weight.
1459    ///
1460    /// Called by the engine when an agent returns a 429/rate limit error.
1461    pub fn record_rate_limit(&mut self, agent: &str) {
1462        self.weights.record_rate_limit(agent);
1463    }
1464
1465    /// Record a successful task completion, restoring agent weight.
1466    pub fn record_success(&mut self, agent: &str) {
1467        self.weights.record_success(agent);
1468    }
1469
1470    /// Tick weight recovery for all agents.
1471    ///
1472    /// Called periodically by the engine to gradually restore weights
1473    /// as rate limit windows expire.
1474    pub fn tick_weight_recovery(&mut self) {
1475        self.weights.tick_recovery();
1476    }
1477
1478    /// Store routing result in the task store.
1479    pub async fn store_route_result(
1480        &self,
1481        task_id: &str,
1482        result: &RouteResult,
1483        store: &std::sync::Arc<TaskStore>,
1484        repo: &str,
1485    ) -> anyhow::Result<()> {
1486        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1487            store
1488                .set_fields(
1489                    store_id,
1490                    &[
1491                        ("agent", serde_json::json!(result.agent)),
1492                        ("complexity", serde_json::json!(result.complexity)),
1493                        ("route_reason", serde_json::json!(result.reason)),
1494                        (
1495                            "model",
1496                            serde_json::json!(result.model.as_deref().unwrap_or("")),
1497                        ),
1498                    ],
1499                )
1500                .await?;
1501        }
1502        Ok(())
1503    }
1504}
1505
1506/// Retrieve routing result from the task store.
1507pub async fn get_route_result(
1508    store: &std::sync::Arc<TaskStore>,
1509    repo: &str,
1510    task_id: &str,
1511) -> Result<RouteResult, RouteResultError> {
1512    let store_id = store
1513        .resolve_task_id(repo, task_id)
1514        .await
1515        .map_err(RouteResultError::Other)?
1516        .ok_or_else(|| {
1517            RouteResultError::Other(anyhow::anyhow!("task {task_id} not found in store"))
1518        })?;
1519    let task = store.get(store_id).await.map_err(RouteResultError::Other)?;
1520
1521    let agent = task
1522        .agent
1523        .clone()
1524        .filter(|a| !a.is_empty())
1525        .ok_or_else(|| RouteResultError::NoAgent {
1526            task_id: task_id.to_string(),
1527        })?;
1528
1529    let complexity = if task.complexity.is_empty() {
1530        "medium".to_string()
1531    } else {
1532        task.complexity.clone()
1533    };
1534
1535    let reason = task.route_reason.clone();
1536    let model = task.model.clone().filter(|m| !m.is_empty());
1537
1538    // Validate that the stored model is valid for the resolved agent.
1539    // Agent-specific aliases (e.g. "opus") can leak across agents during
1540    // failover, so we verify against the router's model pools (#1604).
1541    let config = RouterConfig::from_config();
1542    let model = model.filter(|m| {
1543        let complexities = ["simple", "medium", "complex", "review"];
1544        // Collect all model pools configured for this agent across complexity tiers.
1545        let all_pools: Vec<Vec<String>> = complexities
1546            .iter()
1547            .filter_map(|comp| config.model_pool_for_complexity(&agent, comp))
1548            .collect();
1549        // If the agent has no model pools configured, we can't validate — pass through.
1550        if all_pools.is_empty() {
1551            return true;
1552        }
1553        let valid = all_pools.iter().any(|pool| pool.contains(m));
1554        if !valid {
1555            tracing::warn!(
1556                task_id,
1557                agent,
1558                model = %m,
1559                "discarding stale model: not in agent's model pools"
1560            );
1561        }
1562        valid
1563    });
1564
1565    // If a stale model was discarded, update the database to clear the model
1566    // and record a diagnostic last_error so task_runs.error is populated if
1567    // the subsequent run fails (fixes issue #2527).
1568    let original_model = task.model.clone().filter(|m| !m.is_empty());
1569    if original_model.is_some() && model.is_none() {
1570        let stale_model = original_model.as_deref().unwrap_or("unknown");
1571        let stale_err = format!("stale model discarded: {stale_model} not in {agent} model pools");
1572        if let Err(e) = store
1573            .set_fields(
1574                store_id,
1575                &[
1576                    ("model", serde_json::json!("")),
1577                    ("last_error", serde_json::json!(stale_err)),
1578                ],
1579            )
1580            .await
1581        {
1582            // Log the error so transient DB failures are visible and diagnosable.
1583            tracing::warn!(task_id = %task_id, error = %e, "failed to clear stale model from store — will retry on next dispatch");
1584        }
1585    }
1586
1587    Ok(RouteResult {
1588        agent,
1589        model,
1590        complexity,
1591        estimate: 0,
1592        reason,
1593        profile: AgentProfile::default(),
1594        selected_skills: vec![],
1595        warning: None,
1596    })
1597}
1598
1599#[cfg(test)]
1600mod tests {
1601    use super::config::DEFAULT_AGENTS;
1602    use super::llm::LlmRouteResponse;
1603    use super::strategies;
1604    use super::weights::{
1605        AgentWeights, RateLimitState, DEFAULT_WEIGHT, MIN_WEIGHT, RATE_LIMIT_DECAY, RECOVERY_DELAY,
1606    };
1607    use super::*;
1608    use crate::backends::{ExternalId, ExternalTask};
1609    use crate::engine::cooldown::{
1610        clear_agent_degraded, is_agent_degraded, is_model_in_cooldown, mark_agent_degraded,
1611        record_model_failure, set_model_cooldown,
1612    };
1613    use crate::store::TaskStore;
1614    use serial_test::serial;
1615    use std::time::{Duration, Instant};
1616
1617    // Test-only delegates so tests can call router.parse_llm_response() and
1618    // router.check_routing_sanity() directly without referencing llm_router.
1619    impl Router {
1620        fn parse_llm_response(&self, response: &str) -> anyhow::Result<LlmRouteResponse> {
1621            self.llm_router.parse_llm_response("claude", response)
1622        }
1623
1624        fn check_routing_sanity(
1625            &self,
1626            task: &ExternalTask,
1627            agent: &str,
1628            profile: &AgentProfile,
1629        ) -> Option<String> {
1630            self.llm_router.check_routing_sanity(task, agent, profile)
1631        }
1632    }
1633
1634    async fn test_store() -> std::sync::Arc<TaskStore> {
1635        std::sync::Arc::new(TaskStore::open_memory().await.unwrap())
1636    }
1637
1638    fn create_test_task(id: &str, title: &str, labels: Vec<String>) -> ExternalTask {
1639        ExternalTask {
1640            id: ExternalId(id.to_string()),
1641            title: title.to_string(),
1642            body: "Test body".to_string(),
1643            state: "open".to_string(),
1644            labels,
1645            author: "test".to_string(),
1646            created_at: "2026-01-01T00:00:00Z".to_string(),
1647            updated_at: "2026-01-01T00:00:00Z".to_string(),
1648            url: format!("https://github.com/test/test/issues/{id}"),
1649        }
1650    }
1651
1652    #[serial(cooldown_state)]
1653    #[test]
1654    fn extract_agent_from_labels() {
1655        let mut config = RouterConfig::default();
1656        // Ensure a concrete model exists for the labeled agent so the label
1657        // override is honored by the router (routing requires a model).
1658        config
1659            .model_map
1660            .entry("medium".to_string())
1661            .or_default()
1662            .insert("claude".to_string(), vec!["haiku".to_string()]);
1663
1664        assert_eq!(
1665            strategies::extract_agent_from_labels(&config.agents, &["agent:claude".to_string()]),
1666            Some("claude".to_string())
1667        );
1668        assert_eq!(
1669            strategies::extract_agent_from_labels(&config.agents, &["agent:codex".to_string()]),
1670            Some("codex".to_string())
1671        );
1672        assert_eq!(
1673            strategies::extract_agent_from_labels(&config.agents, &["agent:opencode".to_string()]),
1674            Some("opencode".to_string())
1675        );
1676        assert_eq!(
1677            strategies::extract_agent_from_labels(&config.agents, &["status:new".to_string()]),
1678            None
1679        );
1680        // Verify kimi and minimax are recognized from labels
1681        assert_eq!(
1682            strategies::extract_agent_from_labels(&config.agents, &["agent:kimi".to_string()]),
1683            Some("kimi".to_string())
1684        );
1685        assert_eq!(
1686            strategies::extract_agent_from_labels(&config.agents, &["agent:minimax".to_string()]),
1687            Some("minimax".to_string())
1688        );
1689    }
1690
1691    #[serial(cooldown_state)]
1692    #[tokio::test]
1693    async fn all_agents_cooled_error_names_degraded_agents() {
1694        crate::engine::cooldown::reset_global_state().await;
1695        mark_agent_degraded("claude");
1696        mark_agent_degraded("grok");
1697        let err = AllAgentsCooledError::new("all agents".to_string(), None);
1698        let msg = err.to_string();
1699        assert!(
1700            msg.contains("degraded: claude, grok"),
1701            "ALL AGENTS COOLED must name degraded agents, got {msg}"
1702        );
1703        assert_eq!(err.degraded(), &["claude".to_string(), "grok".to_string()]);
1704        clear_agent_degraded("claude");
1705        clear_agent_degraded("grok");
1706    }
1707
1708    #[serial(cooldown_state)]
1709    #[test]
1710    fn default_agents_constant() {
1711        assert_eq!(DEFAULT_AGENTS.len(), 7);
1712        assert!(DEFAULT_AGENTS.contains(&"claude"));
1713        assert!(DEFAULT_AGENTS.contains(&"kimi"));
1714        assert!(DEFAULT_AGENTS.contains(&"minimax"));
1715        assert!(DEFAULT_AGENTS.contains(&"glm"));
1716        assert!(DEFAULT_AGENTS.contains(&"grok"));
1717    }
1718
1719    #[serial(cooldown_state)]
1720    #[test]
1721    fn extract_complexity_from_labels() {
1722        assert_eq!(
1723            strategies::extract_complexity_from_labels(&["complexity:simple".to_string()]),
1724            "simple"
1725        );
1726        assert_eq!(
1727            strategies::extract_complexity_from_labels(&["complexity:medium".to_string()]),
1728            "medium"
1729        );
1730        assert_eq!(
1731            strategies::extract_complexity_from_labels(&["complexity:complex".to_string()]),
1732            "complex"
1733        );
1734        assert_eq!(
1735            strategies::extract_complexity_from_labels(&["status:new".to_string()]),
1736            "medium"
1737        );
1738    }
1739
1740    #[serial(cooldown_state)]
1741    #[test]
1742    fn route_result_serialization() {
1743        let result = RouteResult {
1744            agent: "claude".to_string(),
1745            model: Some("sonnet".to_string()),
1746            complexity: "medium".to_string(),
1747            estimate: 5,
1748            reason: "test".to_string(),
1749            profile: AgentProfile {
1750                role: "backend".to_string(),
1751                skills: vec!["rust".to_string()],
1752                tools: vec!["git".to_string()],
1753                constraints: vec![],
1754            },
1755            selected_skills: vec!["gh".to_string()],
1756            warning: None,
1757        };
1758
1759        let json = serde_json::to_string(&result).unwrap();
1760        assert!(json.contains("claude"));
1761        assert!(json.contains("sonnet"));
1762
1763        let deserialized: RouteResult = serde_json::from_str(&json).unwrap();
1764        assert_eq!(deserialized.agent, "claude");
1765        assert_eq!(deserialized.model, Some("sonnet".to_string()));
1766    }
1767
1768    #[serial(cooldown_state)]
1769    #[test]
1770    fn router_config_default() {
1771        let mut config = RouterConfig::default();
1772        // Ensure a concrete model exists for the labeled agent so the label
1773        // override is honored by the router (routing requires a model).
1774        config
1775            .model_map
1776            .entry("medium".to_string())
1777            .or_default()
1778            .insert("claude".to_string(), vec!["haiku".to_string()]);
1779
1780        assert_eq!(config.mode, "round_robin");
1781        assert_eq!(config.router_agent, "claude");
1782        assert_eq!(config.router_model, "haiku");
1783        assert_eq!(config.fallback_executor, "codex");
1784        assert_eq!(config.max_route_attempts, 3);
1785        assert!(!config.allowed_tools.is_empty());
1786        assert!(!config.default_skills.is_empty());
1787
1788        // Verify configurable agents list includes all 7 agents
1789        assert_eq!(config.agents.len(), 7);
1790        assert!(config.agents.contains(&"claude".to_string()));
1791        assert!(config.agents.contains(&"codex".to_string()));
1792        assert!(config.agents.contains(&"opencode".to_string()));
1793        assert!(config.agents.contains(&"kimi".to_string()));
1794        assert!(config.agents.contains(&"minimax".to_string()));
1795        assert!(config.agents.contains(&"glm".to_string()));
1796        assert!(config.agents.contains(&"grok".to_string()));
1797    }
1798
1799    #[serial(cooldown_state)]
1800    #[test]
1801    fn model_map_lookup_returns_none_without_config() {
1802        // Default config has no hardcoded models — config.yml is the source of truth.
1803        let config = RouterConfig::default();
1804        assert!(config
1805            .model_for_complexity("claude", "simple", "")
1806            .is_none());
1807        assert!(config
1808            .model_for_complexity("claude", "medium", "")
1809            .is_none());
1810        assert!(config
1811            .model_for_complexity("claude", "complex", "")
1812            .is_none());
1813        assert!(config
1814            .model_for_complexity("opencode", "review", "")
1815            .is_none());
1816    }
1817
1818    #[serial(cooldown_state)]
1819    #[test]
1820    fn model_map_lookup_returns_configured_value() {
1821        // When model_map is populated (from config), it is returned correctly.
1822        let mut config = RouterConfig::default();
1823        config
1824            .model_map
1825            .entry("simple".to_string())
1826            .or_default()
1827            .insert("claude".to_string(), vec!["haiku".to_string()]);
1828        assert_eq!(
1829            config.model_for_complexity("claude", "simple", ""),
1830            Some("haiku".to_string())
1831        );
1832    }
1833
1834    #[serial(cooldown_state)]
1835    #[tokio::test]
1836    async fn model_pool_selection_skips_cooled() {
1837        crate::engine::cooldown::reset_global_state().await;
1838        use std::collections::HashMap;
1839
1840        let mut config = RouterConfig::default();
1841        // Set up a two-model pool for opencode/simple
1842        config
1843            .model_map
1844            .entry("simple".to_string())
1845            .or_default()
1846            .insert(
1847                "opencode".to_string(),
1848                vec!["model-a".to_string(), "model-b".to_string()],
1849            );
1850
1851        // Before any cooldown, both models are candidates
1852        let m = config.model_for_complexity("opencode", "simple", "task-1");
1853        assert!(m == Some("model-a".to_string()) || m == Some("model-b".to_string()));
1854
1855        // Cool model-a
1856        record_model_failure("opencode", "model-a").await;
1857        assert!(is_model_in_cooldown("opencode", "model-a"));
1858
1859        // Now only model-b should be returned
1860        // (try many task_ids to rule out hash coincidence)
1861        for i in 0..20 {
1862            let result = config.model_for_complexity("opencode", "simple", &i.to_string());
1863            assert_eq!(
1864                result,
1865                Some("model-b".to_string()),
1866                "expected model-b when model-a is cooled, got {result:?} for task_id={i}"
1867            );
1868        }
1869
1870        // Cool model-b too — now all models are cooled
1871        record_model_failure("opencode", "model-b").await;
1872        // All cooled → return None so the caller can fall back to a different agent
1873        let fallback = config.model_for_complexity("opencode", "simple", "task-fallback");
1874        assert_eq!(fallback, None);
1875        let _ = HashMap::<(), ()>::new(); // suppress unused import lint
1876    }
1877
1878    #[serial(cooldown_state)]
1879    #[test]
1880    fn model_pool_single_string_backward_compat() {
1881        // Single-item pools behave identically to the old string format.
1882        // Manually insert a single-item pool and verify it is returned correctly.
1883        let mut config = RouterConfig::default();
1884        config
1885            .model_map
1886            .entry("simple".to_string())
1887            .or_default()
1888            .insert("claude".to_string(), vec!["haiku".to_string()]);
1889        let m = config.model_for_complexity("claude", "simple", "any-task");
1890        assert_eq!(m, Some("haiku".to_string()));
1891    }
1892
1893    #[serial(cooldown_state)]
1894    #[test]
1895    fn parse_llm_response_direct_json() {
1896        let config = RouterConfig::default();
1897        let router = Router::new(config);
1898
1899        let response = r#"{
1900            "executor": "claude",
1901            "complexity": "complex",
1902            "reason": "requires architecture analysis",
1903            "profile": {
1904                "role": "architect",
1905                "skills": ["rust", "design"],
1906                "tools": ["git", "rg"],
1907                "constraints": []
1908            },
1909            "selected_skills": ["gh"]
1910        }"#;
1911
1912        let parsed = router.parse_llm_response(response).unwrap();
1913        assert_eq!(parsed.executor, "claude");
1914        assert_eq!(parsed.complexity, "complex");
1915        assert_eq!(parsed.reason, "requires architecture analysis");
1916        assert_eq!(parsed.profile.role, "architect");
1917    }
1918
1919    #[serial(cooldown_state)]
1920    #[test]
1921    fn parse_llm_response_markdown_fenced() {
1922        let config = RouterConfig::default();
1923        let router = Router::new(config);
1924
1925        let response = r#"Here's my analysis:
1926
1927```json
1928{
1929    "executor": "codex",
1930    "complexity": "medium",
1931    "reason": "coding task",
1932    "profile": {
1933        "role": "developer",
1934        "skills": ["coding"],
1935        "tools": [],
1936        "constraints": []
1937    },
1938    "selected_skills": []
1939}
1940```
1941
1942Hope that helps!"#;
1943
1944        let parsed = router.parse_llm_response(response).unwrap();
1945        assert_eq!(parsed.executor, "codex");
1946        assert_eq!(parsed.complexity, "medium");
1947    }
1948
1949    #[serial(cooldown_state)]
1950    #[test]
1951    fn parse_llm_response_claude_envelope() {
1952        let config = RouterConfig::default();
1953        let router = Router::new(config);
1954
1955        // Claude --output-format json wraps the response in an envelope
1956        let response = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1500,"result":"{\"executor\":\"claude\",\"complexity\":\"complex\",\"reason\":\"requires deep analysis\",\"profile\":{\"role\":\"architect\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}","usage":{"input_tokens":100,"output_tokens":50}}"#;
1957
1958        let parsed = router.parse_llm_response(response).unwrap();
1959        assert_eq!(parsed.executor, "claude");
1960        assert_eq!(parsed.complexity, "complex");
1961        assert_eq!(parsed.reason, "requires deep analysis");
1962    }
1963
1964    #[serial(cooldown_state)]
1965    #[test]
1966    fn parse_llm_response_claude_envelope_with_code_block() {
1967        let config = RouterConfig::default();
1968        let router = Router::new(config);
1969
1970        // Claude may return the JSON inside a code block within the envelope
1971        let inner = "```json\n{\"executor\":\"codex\",\"complexity\":\"simple\",\"reason\":\"simple fix\",\"profile\":{\"role\":\"developer\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}\n```";
1972        let envelope = serde_json::json!({
1973            "type": "result",
1974            "subtype": "success",
1975            "is_error": false,
1976            "result": inner,
1977            "usage": {"input_tokens": 50, "output_tokens": 30}
1978        });
1979
1980        let parsed = router.parse_llm_response(&envelope.to_string()).unwrap();
1981        assert_eq!(parsed.executor, "codex");
1982        assert_eq!(parsed.complexity, "simple");
1983    }
1984
1985    #[serial(cooldown_state)]
1986    #[test]
1987    fn parse_llm_response_claude_envelope_error() {
1988        let config = RouterConfig::default();
1989        let router = Router::new(config);
1990
1991        let response = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1992
1993        let err = router.parse_llm_response(response).unwrap_err();
1994        assert!(err.to_string().contains("error"), "got: {err}");
1995    }
1996
1997    #[serial(cooldown_state)]
1998    #[test]
1999    fn parse_llm_response_empty() {
2000        let config = RouterConfig::default();
2001        let router = Router::new(config);
2002
2003        let err = router.parse_llm_response("").unwrap_err();
2004        assert!(err.to_string().contains("empty"), "got: {err}");
2005    }
2006
2007    /// Claude envelope where result is a JSON object, not a string.
2008    /// Some Claude versions may return the result as a nested object.
2009    #[serial(cooldown_state)]
2010    #[test]
2011    fn parse_llm_response_claude_envelope_result_as_object() {
2012        let config = RouterConfig::default();
2013        let router = Router::new(config);
2014
2015        let response = r#"{"type":"result","subtype":"success","is_error":false,"result":{"executor":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]},"usage":{"input_tokens":100,"output_tokens":50}}"#;
2016
2017        let parsed = router.parse_llm_response(response);
2018        assert!(
2019            parsed.is_ok(),
2020            "should handle result-as-object: {}",
2021            parsed.unwrap_err()
2022        );
2023        assert_eq!(parsed.unwrap().executor, "claude");
2024    }
2025
2026    /// LLM returns "agent" instead of "executor" — common hallucination.
2027    #[serial(cooldown_state)]
2028    #[test]
2029    fn parse_llm_response_agent_instead_of_executor() {
2030        let config = RouterConfig::default();
2031        let router = Router::new(config);
2032
2033        let response = r#"{"agent":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]}"#;
2034
2035        let parsed = router.parse_llm_response(response);
2036        assert!(
2037            parsed.is_ok(),
2038            "should accept 'agent' alias: {}",
2039            parsed.unwrap_err()
2040        );
2041        assert_eq!(parsed.unwrap().executor, "claude");
2042    }
2043
2044    /// LLM returns minimal JSON without profile or reason.
2045    #[serial(cooldown_state)]
2046    #[test]
2047    fn parse_llm_response_minimal_json() {
2048        let config = RouterConfig::default();
2049        let router = Router::new(config);
2050
2051        let response = r#"{"executor":"codex","complexity":"simple"}"#;
2052
2053        let parsed = router.parse_llm_response(response);
2054        assert!(
2055            parsed.is_ok(),
2056            "should accept minimal JSON: {}",
2057            parsed.unwrap_err()
2058        );
2059        assert_eq!(parsed.unwrap().executor, "codex");
2060    }
2061
2062    // ── Real fixture tests: route prompt → response → parsed result ──
2063
2064    /// Real Claude envelope with result as escaped JSON string.
2065    #[serial(cooldown_state)]
2066    #[test]
2067    fn fixture_route_response_string() {
2068        let config = RouterConfig::default();
2069        let router = Router::new(config);
2070
2071        let response = include_str!("../../../tests/fixtures/route-response-string.json");
2072        let parsed = router.parse_llm_response(response).unwrap();
2073        assert_eq!(parsed.executor, "codex");
2074        assert_eq!(parsed.complexity, "medium");
2075        assert!(!parsed.reason.is_empty());
2076        assert!(!parsed.profile.role.is_empty());
2077    }
2078
2079    /// Real Claude envelope with result as JSON object (not string).
2080    /// This was the root cause of all router failures on 2026-02-27.
2081    #[serial(cooldown_state)]
2082    #[test]
2083    fn fixture_route_response_object() {
2084        let config = RouterConfig::default();
2085        let router = Router::new(config);
2086
2087        let response = include_str!("../../../tests/fixtures/route-response-object.json");
2088        let parsed = router.parse_llm_response(response).unwrap();
2089        assert_eq!(parsed.executor, "claude");
2090        assert_eq!(parsed.complexity, "complex");
2091    }
2092
2093    /// Real Claude envelope with result as string containing markdown + JSON.
2094    #[serial(cooldown_state)]
2095    #[test]
2096    fn fixture_route_response_markdown() {
2097        let config = RouterConfig::default();
2098        let router = Router::new(config);
2099
2100        let response = include_str!("../../../tests/fixtures/route-response-markdown.json");
2101        let parsed = router.parse_llm_response(response).unwrap();
2102        assert_eq!(parsed.executor, "codex");
2103        assert_eq!(parsed.complexity, "medium");
2104    }
2105
2106    /// Claude envelope with escaped JSON string containing newlines and markdown.
2107    #[serial(cooldown_state)]
2108    #[test]
2109    fn parse_llm_response_claude_envelope_with_prose() {
2110        let config = RouterConfig::default();
2111        let router = Router::new(config);
2112
2113        let inner = "Based on the task description, here's my routing decision:\n\n```json\n{\"executor\":\"codex\",\"complexity\":\"medium\",\"reason\":\"standard feature work\",\"profile\":{\"role\":\"developer\",\"skills\":[\"rust\"],\"tools\":[\"git\",\"cargo\"],\"constraints\":[]},\"selected_skills\":[]}\n```\n\nThis task involves adding slash commands which is standard development work.";
2114        let envelope = serde_json::json!({
2115            "type": "result",
2116            "subtype": "success",
2117            "is_error": false,
2118            "result": inner,
2119            "usage": {"input_tokens": 100, "output_tokens": 80}
2120        });
2121
2122        let parsed = router.parse_llm_response(&envelope.to_string());
2123        assert!(
2124            parsed.is_ok(),
2125            "should handle prose + code block in envelope: {}",
2126            parsed.unwrap_err()
2127        );
2128        assert_eq!(parsed.unwrap().executor, "codex");
2129    }
2130
2131    #[serial(cooldown_state)]
2132    #[tokio::test]
2133    async fn route_round_robin_basic() {
2134        crate::engine::cooldown::reset_global_state().await;
2135        // Force at least one agent to be available for testing
2136        // In real usage, discover_agents finds installed CLIs
2137        let config = RouterConfig {
2138            mode: "round_robin".to_string(),
2139            ..Default::default()
2140        };
2141
2142        // Create router with mock available agents
2143        let agents = vec!["claude".to_string(), "codex".to_string()];
2144        let mut weights = AgentWeights::default();
2145        weights.ensure_agents(&agents);
2146        let router = Router {
2147            config,
2148            available_agents: agents,
2149            weights,
2150            llm_router: std::sync::Arc::new(LlmRouter::new()),
2151            ollama_router: None,
2152            rr_index: 0,
2153            last_agent: None,
2154            review_rr_index: 0,
2155            router_pool: vec![],
2156            pool_index: 0,
2157        };
2158
2159        let task = create_test_task("1", "Test task", vec![]);
2160        let result =
2161            strategies::route_via_round_robin(&router.available_agents, &router.config, &task)
2162                .unwrap();
2163
2164        // Task 1 % 2 agents = agent at index 1 = codex
2165        assert_eq!(result.agent, "codex");
2166        assert_eq!(result.reason, "round_robin (task 1 % 2 agents)");
2167    }
2168
2169    #[serial(cooldown_state)]
2170    #[tokio::test]
2171    async fn route_uses_label_override() {
2172        crate::engine::cooldown::reset_global_state().await;
2173        let mut config = RouterConfig::default();
2174        // Ensure a concrete model exists so the label override can be honored
2175        // in the test environment where no external config.yml is present.
2176        config
2177            .model_map
2178            .entry("medium".to_string())
2179            .or_default()
2180            .insert("claude".to_string(), vec!["haiku".to_string()]);
2181        let agents = vec!["claude".to_string(), "codex".to_string()];
2182        let mut weights = AgentWeights::default();
2183        weights.ensure_agents(&agents);
2184        let mut router = Router {
2185            config,
2186            available_agents: agents,
2187            weights,
2188            llm_router: std::sync::Arc::new(LlmRouter::new()),
2189            ollama_router: None,
2190            rr_index: 0,
2191            last_agent: None,
2192            review_rr_index: 0,
2193            router_pool: vec![],
2194            pool_index: 0,
2195        };
2196
2197        let task = create_test_task("1", "Test", vec!["agent:claude".to_string()]);
2198        let store = test_store().await;
2199
2200        // Should use label override, not LLM
2201        let result = router.route(&task, &store, "test/repo").await.unwrap();
2202        assert_eq!(result.agent, "claude");
2203        assert!(result.reason.contains("label"));
2204    }
2205
2206    #[serial(cooldown_state)]
2207    #[test]
2208    fn check_routing_sanity_warnings() {
2209        let config = RouterConfig::default();
2210        let router = Router::new(config);
2211
2212        // Backend task routed to claude should warn
2213        let task = create_test_task("1", "Fix API", vec!["backend".to_string()]);
2214        let profile = AgentProfile {
2215            role: "general".to_string(),
2216            skills: vec!["api".to_string()],
2217            tools: vec![],
2218            constraints: vec![],
2219        };
2220        let warning = router.check_routing_sanity(&task, "claude", &profile);
2221        assert!(warning.is_some());
2222        assert!(warning.unwrap().contains("backend"));
2223
2224        // Docs task routed to codex should warn
2225        let task = create_test_task("1", "Update README", vec!["docs".to_string()]);
2226        let warning = router.check_routing_sanity(&task, "codex", &profile);
2227        assert!(warning.is_some());
2228        assert!(warning.unwrap().contains("docs"));
2229
2230        // Normal routing should not warn
2231        let task = create_test_task("1", "Fix bug", vec!["bug".to_string()]);
2232        let warning = router.check_routing_sanity(&task, "codex", &profile);
2233        assert!(warning.is_none());
2234    }
2235
2236    #[serial(cooldown_state)]
2237    #[tokio::test]
2238    async fn router_reload_preserves_structure() {
2239        crate::engine::cooldown::reset_global_state().await;
2240        let config = RouterConfig::default();
2241        let agents = vec!["claude".to_string()];
2242        let mut weights = AgentWeights::default();
2243        weights.ensure_agents(&agents);
2244        let mut router = Router {
2245            config,
2246            available_agents: agents,
2247            weights,
2248            llm_router: std::sync::Arc::new(LlmRouter::new()),
2249            ollama_router: None,
2250            rr_index: 0,
2251            last_agent: None,
2252            review_rr_index: 0,
2253            router_pool: vec![],
2254            pool_index: 0,
2255        };
2256
2257        // Reload — should re-read config and remain valid
2258        router.reload_async().await;
2259
2260        // After reload, mode should be a valid value (llm, local, or round_robin)
2261        assert!(
2262            router.config.mode == "llm"
2263                || router.config.mode == "round_robin"
2264                || router.config.mode == "local",
2265            "mode should be 'llm', 'local', or 'round_robin', got '{}'",
2266            router.config.mode
2267        );
2268        // Fallback executor should always be set
2269        assert!(!router.config.fallback_executor.is_empty());
2270        // Tools should always be populated
2271        assert!(!router.config.allowed_tools.is_empty());
2272    }
2273
2274    // --- Weighted round-robin tests ---
2275
2276    #[serial(cooldown_state)]
2277    #[test]
2278    fn rate_limit_state_defaults_to_full_weight() {
2279        let state = RateLimitState::default();
2280        assert_eq!(state.weight, DEFAULT_WEIGHT);
2281        assert_eq!(state.consecutive_hits, 0);
2282        assert!(state.last_limited_at.is_none());
2283        assert!(!state.is_limited());
2284    }
2285
2286    #[serial(cooldown_state)]
2287    #[test]
2288    fn rate_limit_state_decays_on_hit() {
2289        let mut state = RateLimitState::default();
2290        state.record_rate_limit();
2291
2292        assert!(state.weight < DEFAULT_WEIGHT);
2293        assert_eq!(state.consecutive_hits, 1);
2294        assert!(state.last_limited_at.is_some());
2295        assert!(state.is_limited());
2296
2297        // Weight should be DEFAULT_WEIGHT * RATE_LIMIT_DECAY
2298        let expected = DEFAULT_WEIGHT * RATE_LIMIT_DECAY;
2299        assert!((state.weight - expected).abs() < 1e-10);
2300    }
2301
2302    #[serial(cooldown_state)]
2303    #[test]
2304    fn rate_limit_state_never_drops_below_min() {
2305        let mut state = RateLimitState::default();
2306
2307        // Hit many times
2308        for _ in 0..100 {
2309            state.record_rate_limit();
2310        }
2311
2312        assert!(state.weight >= MIN_WEIGHT);
2313        assert_eq!(state.consecutive_hits, 100);
2314    }
2315
2316    #[serial(cooldown_state)]
2317    #[test]
2318    fn rate_limit_state_recovers_on_success() {
2319        let mut state = RateLimitState::default();
2320
2321        // Decay first
2322        state.record_rate_limit();
2323        state.record_rate_limit();
2324        let after_decay = state.weight;
2325
2326        // Record success
2327        state.record_success();
2328        assert!(state.weight > after_decay);
2329        assert_eq!(state.consecutive_hits, 0);
2330    }
2331
2332    #[serial(cooldown_state)]
2333    #[test]
2334    fn rate_limit_state_success_caps_at_default() {
2335        let mut state = RateLimitState::default();
2336
2337        // Already at full weight, success shouldn't exceed it
2338        state.record_success();
2339        assert_eq!(state.weight, DEFAULT_WEIGHT);
2340    }
2341
2342    #[serial(cooldown_state)]
2343    #[test]
2344    fn agent_weights_ensure_agents() {
2345        let mut weights = AgentWeights::default();
2346        let agents = vec!["claude".to_string(), "codex".to_string()];
2347        weights.ensure_agents(&agents);
2348
2349        assert_eq!(weights.states.len(), 2);
2350        assert_eq!(weights.get_weight("claude"), DEFAULT_WEIGHT);
2351        assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2352    }
2353
2354    #[serial(cooldown_state)]
2355    #[test]
2356    fn agent_weights_ensure_agents_preserves_existing() {
2357        let mut weights = AgentWeights::default();
2358        let agents = vec!["claude".to_string(), "codex".to_string()];
2359        weights.ensure_agents(&agents);
2360
2361        // Reduce claude's weight
2362        weights.record_rate_limit("claude");
2363        let claude_weight = weights.get_weight("claude");
2364        assert!(claude_weight < DEFAULT_WEIGHT);
2365
2366        // Ensure agents again — claude's weight should be preserved
2367        let agents2 = vec![
2368            "claude".to_string(),
2369            "codex".to_string(),
2370            "opencode".to_string(),
2371        ];
2372        weights.ensure_agents(&agents2);
2373
2374        assert_eq!(weights.get_weight("claude"), claude_weight);
2375        assert_eq!(weights.get_weight("opencode"), DEFAULT_WEIGHT);
2376    }
2377
2378    #[test]
2379    fn default_missing_weight_uses_minimum_of_configured() {
2380        use super::weights::default_missing_weight;
2381
2382        let mut configured = std::collections::HashMap::new();
2383        configured.insert("claude".to_string(), 0.2);
2384        configured.insert("codex".to_string(), 0.2);
2385        configured.insert("opencode".to_string(), 0.2);
2386        configured.insert("kimi".to_string(), 0.2);
2387
2388        // minimax omitted from router.weights — must not silently outrank
2389        // every explicitly configured agent (issue #3596).
2390        assert_eq!(default_missing_weight(&configured), 0.2);
2391    }
2392
2393    #[test]
2394    fn default_missing_weight_falls_back_when_none_configured() {
2395        use super::weights::default_missing_weight;
2396
2397        let configured = std::collections::HashMap::new();
2398        assert_eq!(default_missing_weight(&configured), DEFAULT_WEIGHT);
2399    }
2400
2401    #[serial(cooldown_state)]
2402    #[test]
2403    fn ensure_agents_with_weights_missing_agent_does_not_outrank_configured() {
2404        let mut weights = AgentWeights::default();
2405        let mut configured = std::collections::HashMap::new();
2406        configured.insert("claude".to_string(), 0.2);
2407        configured.insert("codex".to_string(), 0.2);
2408        configured.insert("opencode".to_string(), 0.2);
2409        configured.insert("kimi".to_string(), 0.2);
2410
2411        let agents = vec![
2412            "claude".to_string(),
2413            "codex".to_string(),
2414            "opencode".to_string(),
2415            "kimi".to_string(),
2416            "minimax".to_string(),
2417        ];
2418        weights.ensure_agents_with_weights(&agents, &configured);
2419
2420        // minimax was omitted from router.weights but must not get the old
2421        // DEFAULT_WEIGHT of 1.0, which would outrank every configured agent.
2422        assert_eq!(weights.get_weight("minimax"), 0.2);
2423        assert_eq!(weights.get_weight("claude"), 0.2);
2424    }
2425
2426    #[serial(cooldown_state)]
2427    #[test]
2428    fn agent_weights_record_rate_limit() {
2429        let mut weights = AgentWeights::default();
2430        weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2431
2432        weights.record_rate_limit("claude");
2433
2434        assert!(weights.get_weight("claude") < DEFAULT_WEIGHT);
2435        assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2436    }
2437
2438    #[serial(cooldown_state)]
2439    #[test]
2440    fn agent_weights_record_success() {
2441        let mut weights = AgentWeights::default();
2442        weights.ensure_agents(&["claude".to_string()]);
2443
2444        weights.record_rate_limit("claude");
2445        let after_limit = weights.get_weight("claude");
2446
2447        weights.record_success("claude");
2448        assert!(weights.get_weight("claude") > after_limit);
2449    }
2450
2451    #[serial(cooldown_state)]
2452    #[test]
2453    fn agent_weights_snapshot() {
2454        let mut weights = AgentWeights::default();
2455        weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2456        weights.record_rate_limit("claude");
2457
2458        let snap = weights.snapshot();
2459        assert_eq!(snap.len(), 2);
2460
2461        // Snapshot is sorted alphabetically
2462        assert_eq!(snap[0].0, "claude");
2463        assert!(snap[0].1 < DEFAULT_WEIGHT);
2464        assert_eq!(snap[0].2, 1); // 1 hit
2465
2466        assert_eq!(snap[1].0, "codex");
2467        assert_eq!(snap[1].1, DEFAULT_WEIGHT);
2468        assert_eq!(snap[1].2, 0); // 0 hits
2469    }
2470
2471    #[serial(cooldown_state)]
2472    #[test]
2473    fn agent_weights_weighted_select_favors_higher_weight() {
2474        let mut weights = AgentWeights::default();
2475        let agents = vec!["claude".to_string(), "codex".to_string()];
2476        weights.ensure_agents(&agents);
2477
2478        // Heavily penalize claude
2479        for _ in 0..10 {
2480            weights.record_rate_limit("claude");
2481        }
2482
2483        // Run many selections and count
2484        let mut claude_count = 0;
2485        let mut codex_count = 0;
2486        for _ in 0..100 {
2487            match weights.weighted_select(&agents, "task-1") {
2488                Some(ref a) if a == "claude" => claude_count += 1,
2489                Some(ref a) if a == "codex" => codex_count += 1,
2490                _ => {}
2491            }
2492        }
2493
2494        // Codex should get significantly more selections
2495        // (claude weight is near MIN_WEIGHT, codex is at 1.0)
2496        assert!(
2497            codex_count > claude_count,
2498            "codex ({codex_count}) should get more selections than claude ({claude_count})"
2499        );
2500    }
2501
2502    #[serial(cooldown_state)]
2503    #[test]
2504    fn agent_weights_weighted_select_empty_returns_none() {
2505        let weights = AgentWeights::default();
2506        assert!(weights.weighted_select(&[], "task-1").is_none());
2507    }
2508
2509    #[serial(cooldown_state)]
2510    #[test]
2511    fn agent_weights_batch_routing_produces_varied_selections() {
2512        // Regression test: when routing a batch of tasks in the same tick,
2513        // different task IDs should not all select the same agent.
2514        let mut weights = AgentWeights::default();
2515        let agents = vec!["claude".to_string(), "codex".to_string()];
2516        weights.ensure_agents(&agents);
2517
2518        // Simulate batch routing: 20 different task IDs called in rapid succession
2519        let task_ids: Vec<String> = (1..=20).map(|i| format!("task-{i}")).collect();
2520        let selections: Vec<String> = task_ids
2521            .iter()
2522            .filter_map(|id| weights.weighted_select(&agents, id))
2523            .collect();
2524
2525        // With 20 tasks and 2 agents of equal weight, we should see both agents selected.
2526        // The probability of all 20 selecting the same agent by chance is (0.5)^19 < 1e-6.
2527        let claude_count = selections.iter().filter(|a| a.as_str() == "claude").count();
2528        let codex_count = selections.iter().filter(|a| a.as_str() == "codex").count();
2529        assert!(
2530            claude_count > 0 && codex_count > 0,
2531            "batch routing should distribute across agents; got claude={claude_count} codex={codex_count}"
2532        );
2533    }
2534
2535    #[serial(cooldown_state)]
2536    #[test]
2537    fn agent_weights_weighted_select_single_agent() {
2538        let mut weights = AgentWeights::default();
2539        let agents = vec!["claude".to_string()];
2540        weights.ensure_agents(&agents);
2541
2542        let selected = weights.weighted_select(&agents, "task-1");
2543        assert_eq!(selected, Some("claude".to_string()));
2544    }
2545
2546    #[serial(cooldown_state)]
2547    #[test]
2548    fn agent_weights_tick_recovery_restores_weight() {
2549        let mut weights = AgentWeights::default();
2550        weights.ensure_agents(&["claude".to_string()]);
2551
2552        // Set up a rate limit that looks like it happened long ago
2553        let state = weights.states.get_mut("claude").unwrap();
2554        state.weight = 0.5;
2555        state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2556        state.consecutive_hits = 2;
2557
2558        let before = weights.get_weight("claude");
2559        weights.tick_recovery();
2560        let after = weights.get_weight("claude");
2561
2562        assert!(after > before, "weight should increase after recovery tick");
2563    }
2564
2565    #[serial(cooldown_state)]
2566    #[test]
2567    fn agent_weights_tick_recovery_clears_on_full_restore() {
2568        let mut weights = AgentWeights::default();
2569        weights.ensure_agents(&["claude".to_string()]);
2570
2571        // Set weight just below full with an old limit
2572        let state = weights.states.get_mut("claude").unwrap();
2573        state.weight = DEFAULT_WEIGHT - 0.01;
2574        state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2575        state.consecutive_hits = 1;
2576
2577        weights.tick_recovery();
2578
2579        let state = weights.states.get("claude").unwrap();
2580        assert_eq!(state.weight, DEFAULT_WEIGHT);
2581        assert!(state.last_limited_at.is_none());
2582        assert_eq!(state.consecutive_hits, 0);
2583    }
2584
2585    #[serial(cooldown_state)]
2586    #[tokio::test]
2587    async fn route_weighted_round_robin_basic() {
2588        crate::engine::cooldown::reset_global_state().await;
2589        let mut config = RouterConfig {
2590            weighted_round_robin: true,
2591            ..Default::default()
2592        };
2593        // Provide a concrete model for codex so label override can be used.
2594        config
2595            .model_map
2596            .entry("medium".to_string())
2597            .or_default()
2598            .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2599
2600        let agents = vec!["claude".to_string(), "codex".to_string()];
2601        let mut weights = AgentWeights::default();
2602        weights.ensure_agents(&agents);
2603        let mut router = Router {
2604            config,
2605            available_agents: agents,
2606            weights,
2607            llm_router: std::sync::Arc::new(LlmRouter::new()),
2608            ollama_router: None,
2609            rr_index: 0,
2610            last_agent: None,
2611            review_rr_index: 0,
2612            router_pool: vec![],
2613            pool_index: 0,
2614        };
2615
2616        let task = create_test_task("1", "Test task", vec![]);
2617        let store = test_store().await;
2618        let result = router.route(&task, &store, "test/repo").await.unwrap();
2619
2620        // Should use weighted_round_robin
2621        assert!(result.reason.contains("weighted_round_robin"));
2622        assert!(
2623            result.agent == "claude" || result.agent == "codex",
2624            "agent should be claude or codex, got '{}'",
2625            result.agent
2626        );
2627    }
2628
2629    #[serial(cooldown_state)]
2630    #[tokio::test]
2631    async fn route_weighted_round_robin_respects_label_override() {
2632        crate::engine::cooldown::reset_global_state().await;
2633        let mut config = RouterConfig {
2634            weighted_round_robin: true,
2635            ..Default::default()
2636        };
2637        // Provide a concrete model for codex so label override can be used.
2638        config
2639            .model_map
2640            .entry("medium".to_string())
2641            .or_default()
2642            .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2643
2644        let agents = vec!["claude".to_string(), "codex".to_string()];
2645        let mut weights = AgentWeights::default();
2646        weights.ensure_agents(&agents);
2647        let mut router = Router {
2648            config,
2649            available_agents: agents,
2650            weights,
2651            llm_router: std::sync::Arc::new(LlmRouter::new()),
2652            ollama_router: None,
2653            rr_index: 0,
2654            last_agent: None,
2655            review_rr_index: 0,
2656            router_pool: vec![],
2657            pool_index: 0,
2658        };
2659
2660        // Label override should take precedence over weighted routing
2661        let task = create_test_task("1", "Test task", vec!["agent:codex".to_string()]);
2662        let store = test_store().await;
2663        let result = router.route(&task, &store, "test/repo").await.unwrap();
2664        assert_eq!(result.agent, "codex");
2665        assert!(result.reason.contains("label"));
2666    }
2667
2668    #[serial(cooldown_state)]
2669    #[test]
2670    fn router_config_weighted_round_robin_default_false() {
2671        let config = RouterConfig::default();
2672        assert!(!config.weighted_round_robin);
2673    }
2674
2675    #[serial(cooldown_state)]
2676    #[test]
2677    fn router_record_and_recover_weights() {
2678        let config = RouterConfig {
2679            weighted_round_robin: true,
2680            ..Default::default()
2681        };
2682        let mut router = Router::new(config);
2683        // Override discovered agents for test
2684        router.available_agents = vec!["claude".to_string(), "codex".to_string()];
2685        router.weights.ensure_agents(&router.available_agents);
2686
2687        // Record rate limit
2688        router.record_rate_limit("claude");
2689        assert!(router.weights.get_weight("claude") < DEFAULT_WEIGHT);
2690        assert_eq!(router.weights.get_weight("codex"), DEFAULT_WEIGHT);
2691
2692        // Record success for claude
2693        router.record_success("claude");
2694        let after_success = router.weights.get_weight("claude");
2695        assert!(after_success > MIN_WEIGHT);
2696    }
2697
2698    #[serial(cooldown_state)]
2699    #[test]
2700    fn review_rr_index_advances() {
2701        let mut config = RouterConfig::default();
2702        // Set up model_map so has_available_model_for_complexity returns true for test agents
2703        for agent in &["test_a", "test_b", "test_c"] {
2704            config
2705                .model_map
2706                .entry("medium".to_string())
2707                .or_default()
2708                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2709        }
2710        let agents = vec![
2711            "test_a".to_string(),
2712            "test_b".to_string(),
2713            "test_c".to_string(),
2714        ];
2715        let mut weights = AgentWeights::default();
2716        weights.ensure_agents(&agents);
2717        let mut router = Router {
2718            config,
2719            available_agents: agents,
2720            weights,
2721            llm_router: std::sync::Arc::new(LlmRouter::new()),
2722            ollama_router: None,
2723            rr_index: 0,
2724            last_agent: None,
2725            review_rr_index: 0,
2726            router_pool: vec![],
2727            pool_index: 0,
2728        };
2729
2730        let a1 = router.next_round_robin_agent(&[], "medium").unwrap();
2731        let a2 = router.next_round_robin_agent(&[], "medium").unwrap();
2732        let a3 = router.next_round_robin_agent(&[], "medium").unwrap();
2733
2734        // All three agents should appear (order depends on start index)
2735        let mut seen = vec![a1, a2, a3];
2736        seen.sort();
2737        assert_eq!(seen, vec!["test_a", "test_b", "test_c"]);
2738    }
2739
2740    #[serial(cooldown_state)]
2741    #[test]
2742    fn review_rr_fallback_with_cooled_agent() {
2743        // Regression test for #2428: when one agent is in cooldown (candidates.len() <
2744        // available_agents.len()), review_rr_index can exceed candidates.len()-1 causing
2745        // candidates.get(idx) to return None even though candidates is non-empty.
2746        let mut config = RouterConfig::default();
2747        // Register 3 agents in model_map but only 2 will be "available" (simulate cooldown)
2748        for agent in &["test_a", "test_b", "test_c"] {
2749            config
2750                .model_map
2751                .entry("medium".to_string())
2752                .or_default()
2753                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2754        }
2755        // Only 2 candidates available (test_c is in cooldown / not in available_agents)
2756        let agents = vec!["test_a".to_string(), "test_b".to_string()];
2757        let mut weights = AgentWeights::default();
2758        weights.ensure_agents(&agents);
2759        let mut router = Router {
2760            config,
2761            available_agents: agents,
2762            weights,
2763            llm_router: std::sync::Arc::new(LlmRouter::new()),
2764            ollama_router: None,
2765            rr_index: 0,
2766            last_agent: None,
2767            // Start index at 2, which is >= candidates.len() (2), triggering the bug
2768            review_rr_index: 2,
2769            router_pool: vec![],
2770            pool_index: 0,
2771        };
2772
2773        // All candidates excluded — should fall back to first candidate, not None
2774        let result = router.next_round_robin_agent(&["test_a", "test_b"], "medium");
2775        assert!(
2776            result.is_some(),
2777            "next_round_robin_agent must not return None when candidates is non-empty"
2778        );
2779    }
2780
2781    #[serial(cooldown_state)]
2782    #[test]
2783    fn review_rr_index_uses_candidates_len_not_available_len() {
2784        // Regression test for #2449: review_rr_index must advance modulo candidates.len(),
2785        // not available_agents.len(). When an agent has no model for the complexity,
2786        // candidates.len() < available_agents.len() and the old code produced a drifting
2787        // index that caused non-uniform distribution.
2788        let mut config = RouterConfig::default();
2789        // Only register models for test_a and test_b — test_c has no model, so it is
2790        // filtered out by has_available_model_for_complexity → candidates.len() == 2.
2791        for agent in &["test_a", "test_b"] {
2792            config
2793                .model_map
2794                .entry("medium".to_string())
2795                .or_default()
2796                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2797        }
2798        let agents = vec![
2799            "test_a".to_string(),
2800            "test_b".to_string(),
2801            "test_c".to_string(), // available but no model → filtered out of candidates
2802        ];
2803        let mut weights = AgentWeights::default();
2804        weights.ensure_agents(&agents);
2805        let mut router = Router {
2806            config,
2807            available_agents: agents,
2808            weights,
2809            llm_router: std::sync::Arc::new(LlmRouter::new()),
2810            ollama_router: None,
2811            rr_index: 0,
2812            last_agent: None,
2813            review_rr_index: 0,
2814            router_pool: vec![],
2815            pool_index: 0,
2816        };
2817
2818        // With the bug: index advances modulo 3 (available_agents.len()), so the
2819        // sequence would be idx=0→1, idx=1→2, idx=2→0, meaning selection is
2820        // candidates[0%2]=test_a, candidates[1%2]=test_b, candidates[2%2]=test_a —
2821        // looks uniform by accident, but adding a 4th available agent breaks it.
2822        //
2823        // The fix: index advances modulo 2 (candidates.len()), giving a clean 0→1→0→1
2824        // cycle. Verify this by checking review_rr_index after each call.
2825
2826        let r1 = router.next_round_robin_agent(&[], "medium").unwrap();
2827        assert_eq!(
2828            router.review_rr_index, 1,
2829            "index should be 1 after first call"
2830        );
2831
2832        let r2 = router.next_round_robin_agent(&[], "medium").unwrap();
2833        assert_eq!(
2834            router.review_rr_index, 0,
2835            "index should wrap to 0 after second call"
2836        );
2837
2838        let r3 = router.next_round_robin_agent(&[], "medium").unwrap();
2839        assert_eq!(
2840            router.review_rr_index, 1,
2841            "index should be 1 after third call"
2842        );
2843
2844        // Agents must alternate uniformly
2845        assert_ne!(r1, r2, "consecutive calls should select different agents");
2846        assert_eq!(r1, r3, "third call should match first (clean cycle)");
2847    }
2848
2849    #[serial(cooldown_state)]
2850    #[tokio::test]
2851    async fn last_agent_tracks_routing() {
2852        crate::engine::cooldown::reset_global_state().await;
2853        let mut config = RouterConfig {
2854            mode: "round_robin".to_string(),
2855            ..Default::default()
2856        };
2857        config
2858            .model_map
2859            .entry("medium".to_string())
2860            .or_default()
2861            .insert("test_x".to_string(), vec!["test-model-x".to_string()]);
2862        config
2863            .model_map
2864            .entry("medium".to_string())
2865            .or_default()
2866            .insert("test_y".to_string(), vec!["test-model-y".to_string()]);
2867        let agents = vec!["test_x".to_string(), "test_y".to_string()];
2868        let mut weights = AgentWeights::default();
2869        weights.ensure_agents(&agents);
2870        let mut router = Router {
2871            config,
2872            available_agents: agents,
2873            weights,
2874            llm_router: std::sync::Arc::new(LlmRouter::new()),
2875            ollama_router: None,
2876            rr_index: 0,
2877            last_agent: None,
2878            review_rr_index: 0,
2879            router_pool: vec![],
2880            pool_index: 0,
2881        };
2882
2883        assert!(router.last_agent.is_none());
2884
2885        let task = create_test_task("1", "Test routing", vec![]);
2886        let store = test_store().await;
2887        let result = router.route(&task, &store, "test/repo").await.unwrap();
2888
2889        assert!(router.last_agent.is_some());
2890        assert_eq!(router.last_agent.as_deref(), Some(result.agent.as_str()));
2891    }
2892
2893    // ── pool config ───────────────────────────────────────────────────────────
2894
2895    #[serial(cooldown_state)]
2896    #[test]
2897    fn parse_pool_entry_splits_on_first_colon() {
2898        let (agent, model) = super::parse_pool_entry("claude:haiku");
2899        assert_eq!(agent, "claude");
2900        assert_eq!(model, "haiku");
2901    }
2902
2903    #[serial(cooldown_state)]
2904    #[test]
2905    fn parse_pool_entry_model_with_slash() {
2906        // Models like "github-copilot/gpt-5-mini" must not be split on the slash
2907        let (agent, model) = super::parse_pool_entry("opencode:github-copilot/gpt-5-mini");
2908        assert_eq!(agent, "opencode");
2909        assert_eq!(model, "github-copilot/gpt-5-mini");
2910    }
2911
2912    #[serial(cooldown_state)]
2913    #[test]
2914    fn parse_pool_entry_no_colon() {
2915        let (agent, model) = super::parse_pool_entry("claude");
2916        assert_eq!(agent, "claude");
2917        assert_eq!(model, "");
2918    }
2919
2920    #[serial(cooldown_state)]
2921    #[test]
2922    fn effective_pool_uses_configured_pool() {
2923        let config = RouterConfig {
2924            pool: vec!["kimi:k2p5".to_string(), "claude:haiku".to_string()],
2925            ..RouterConfig::default()
2926        };
2927        let pool = config.effective_pool();
2928        assert_eq!(pool, vec!["kimi:k2p5", "claude:haiku"]);
2929    }
2930
2931    #[serial(cooldown_state)]
2932    #[test]
2933    fn effective_pool_falls_back_to_router_agent_model() {
2934        let config = RouterConfig::default();
2935        // Default has no pool → derives from router_agent:router_model
2936        let pool = config.effective_pool();
2937        assert_eq!(pool.len(), 1);
2938        assert!(pool[0].starts_with(&config.router_agent));
2939        assert!(pool[0].contains(&config.router_model));
2940    }
2941
2942    #[serial(cooldown_state)]
2943    #[test]
2944    fn effective_fallback_uses_configured_fallback() {
2945        let config = RouterConfig {
2946            fallback: "claude:haiku".to_string(),
2947            ..RouterConfig::default()
2948        };
2949        assert_eq!(config.effective_fallback(), "claude:haiku");
2950    }
2951
2952    #[serial(cooldown_state)]
2953    #[test]
2954    fn effective_fallback_derives_from_router_agent_model() {
2955        let config = RouterConfig::default();
2956        let fallback = config.effective_fallback();
2957        assert!(fallback.starts_with(&config.router_agent));
2958        assert!(fallback.contains(&config.router_model));
2959    }
2960
2961    // ── pool expansion ────────────────────────────────────────────────────────
2962
2963    #[serial(cooldown_state)]
2964    #[test]
2965    fn expand_pool_basic_entries() {
2966        let config = RouterConfig {
2967            pool: vec!["claude:haiku".to_string(), "kimi:k2p5".to_string()],
2968            ..RouterConfig::default()
2969        };
2970        let expanded = Router::expand_pool(&config);
2971        assert_eq!(expanded.len(), 2);
2972        assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2973        assert_eq!(expanded[1], ("kimi".to_string(), "k2p5".to_string()));
2974    }
2975
2976    #[serial(cooldown_state)]
2977    #[test]
2978    fn expand_pool_uses_effective_pool_when_pool_is_empty() {
2979        let config = RouterConfig {
2980            pool: vec![],
2981            router_agent: "claude".to_string(),
2982            router_model: "haiku".to_string(),
2983            ..RouterConfig::default()
2984        };
2985        let expanded = Router::expand_pool(&config);
2986        assert_eq!(expanded.len(), 1);
2987        assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2988    }
2989
2990    #[serial(cooldown_state)]
2991    #[test]
2992    fn expand_pool_opencode_free_skipped_when_not_installed() {
2993        // `opencode` is unlikely to be in the test environment's PATH.
2994        // If it's absent the entry should be silently skipped and the pool
2995        // should fall back to the effective_fallback entry.
2996        if crate::cmd_cache::command_exists("opencode") {
2997            // If opencode IS installed we can't control what it returns, so skip
2998            return;
2999        }
3000        let config = RouterConfig {
3001            pool: vec!["opencode:free".to_string()],
3002            fallback: "claude:haiku".to_string(),
3003            ..RouterConfig::default()
3004        };
3005        let expanded = Router::expand_pool(&config);
3006        // Should fall back to the fallback entry since all pool entries were skipped
3007        assert_eq!(expanded.len(), 1);
3008        assert_eq!(expanded[0].0, "claude");
3009        assert_eq!(expanded[0].1, "haiku");
3010    }
3011
3012    #[serial(cooldown_state)]
3013    #[test]
3014    fn expand_pool_single_entry_from_default() {
3015        let config = RouterConfig::default();
3016        let expanded = Router::expand_pool(&config);
3017        assert!(
3018            !expanded.is_empty(),
3019            "pool must never be empty after expansion"
3020        );
3021    }
3022
3023    // ── pool round-robin index ────────────────────────────────────────────────
3024
3025    #[serial(cooldown_state)]
3026    #[test]
3027    fn pool_index_initializes_to_zero() {
3028        let config = RouterConfig::default();
3029        let router = Router::new(config);
3030        assert_eq!(router.pool_index, 0);
3031    }
3032
3033    #[serial(cooldown_state)]
3034    #[test]
3035    fn router_pool_non_empty_after_construction() {
3036        let config = RouterConfig::default();
3037        let router = Router::new(config);
3038        assert!(!router.router_pool.is_empty());
3039    }
3040
3041    #[serial(cooldown_state)]
3042    #[tokio::test]
3043    async fn reload_resets_pool_index() {
3044        crate::engine::cooldown::reset_global_state().await;
3045        let config = RouterConfig::default();
3046        let mut router = Router::new(config);
3047        router.pool_index = 5;
3048        router.reload_async().await;
3049        assert_eq!(router.pool_index, 0);
3050    }
3051
3052    #[serial(cooldown_state)]
3053    #[test]
3054    fn pool_index_advances_after_failed_attempt() {
3055        let mut router = Router::new(RouterConfig::default());
3056        router.router_pool = vec![
3057            ("claude".to_string(), "haiku".to_string()),
3058            ("codex".to_string(), "gpt-5.2".to_string()),
3059            (
3060                "opencode".to_string(),
3061                "github-copilot/gpt-5-mini".to_string(),
3062            ),
3063        ];
3064        router.pool_index = 0;
3065
3066        router.advance_pool_index_after_attempt(0, router.router_pool.len());
3067
3068        assert_eq!(router.pool_index, 1);
3069    }
3070
3071    #[serial(cooldown_state)]
3072    #[test]
3073    fn pool_index_wraps_after_last_attempt() {
3074        let mut router = Router::new(RouterConfig::default());
3075        router.router_pool = vec![
3076            ("claude".to_string(), "haiku".to_string()),
3077            ("codex".to_string(), "gpt-5.2".to_string()),
3078            (
3079                "opencode".to_string(),
3080                "github-copilot/gpt-5-mini".to_string(),
3081            ),
3082        ];
3083        router.pool_index = 2;
3084
3085        router.advance_pool_index_after_attempt(2, router.router_pool.len());
3086
3087        assert_eq!(router.pool_index, 0);
3088    }
3089
3090    #[serial(cooldown_state)]
3091    #[test]
3092    fn earliest_pool_cooldown_returns_none_when_no_cooldowns() {
3093        let router = Router::new(RouterConfig::default());
3094        assert!(router.earliest_pool_cooldown().is_none());
3095    }
3096
3097    #[serial(cooldown_state)]
3098    #[tokio::test]
3099    async fn earliest_pool_cooldown_returns_earliest_when_set() {
3100        crate::engine::cooldown::reset_global_state().await;
3101        // Use explicit pool with predictable model names to avoid opencode discovery.
3102        let config = RouterConfig {
3103            pool: vec![
3104                "claude:haiku-4-5-20251001".to_string(),
3105                "opencode:haiku-4-5-20251001".to_string(),
3106            ],
3107            ..Default::default()
3108        };
3109        let router = Router::new(config);
3110        set_model_cooldown("claude", "haiku-4-5-20251001", 300, "silence_detected").await; // 5 min
3111        set_model_cooldown("opencode", "haiku-4-5-20251001", 600, "silence_detected").await; // 10 min
3112
3113        let earliest = router.earliest_pool_cooldown();
3114        assert!(earliest.is_some());
3115        let now = chrono::Utc::now().timestamp();
3116        let remaining = earliest.unwrap() - now;
3117        // Should be close to 300 (5 min, the minimum) — allow 5s tolerance for test runtime
3118        assert!((295..=300).contains(&remaining));
3119    }
3120
3121    #[serial(cooldown_state)]
3122    #[tokio::test]
3123    async fn earliest_pool_cooldown_skips_non_pool_entries() {
3124        crate::engine::cooldown::reset_global_state().await;
3125        // Use explicit pool so we can predict what entries exist.
3126        let config = RouterConfig {
3127            pool: vec![
3128                "claude:pool-model-a".to_string(),
3129                "opencode:pool-model-b".to_string(),
3130            ],
3131            ..Default::default()
3132        };
3133        let router = Router::new(config);
3134        // Set cooldown on a non-pool model — should not affect earliest_pool_cooldown
3135        set_model_cooldown("claude", "opus", 999, "silence_detected").await;
3136        // Set cooldown on a pool entry (opencode:pool-model-b)
3137        set_model_cooldown("opencode", "pool-model-b", 300, "silence_detected").await;
3138
3139        let earliest = router.earliest_pool_cooldown();
3140        assert!(earliest.is_some());
3141        let now = chrono::Utc::now().timestamp();
3142        let remaining = earliest.unwrap() - now;
3143        // Should be ~300 (the pool-model-b cooldown), NOT 999 (claude:opus is not in pool)
3144        assert!((295..=300).contains(&remaining));
3145    }
3146
3147    // ---- Pre-emptive health check: degraded agent exclusion ----
3148
3149    #[serial(cooldown_state)]
3150    #[test]
3151    fn degraded_agent_excluded_from_routing() {
3152        let mut config = RouterConfig::default();
3153        config
3154            .model_map
3155            .entry("medium".to_string())
3156            .or_default()
3157            .insert(
3158                "test_degraded_routing".to_string(),
3159                vec!["model-a".to_string()],
3160            );
3161
3162        let mut router = Router::new(config);
3163        router.available_agents = vec!["test_degraded_routing".to_string()];
3164
3165        // Agent is routable before degradation
3166        assert!(router.agent_is_routable("test_degraded_routing", "medium"));
3167
3168        // Mark as degraded
3169        mark_agent_degraded("test_degraded_routing");
3170
3171        // Now excluded from routing
3172        assert!(!router.agent_is_routable("test_degraded_routing", "medium"));
3173
3174        // available_agents_for_complexity returns empty
3175        assert!(
3176            router.available_agents_for_complexity("medium").is_empty(),
3177            "degraded agent should be excluded from available agents"
3178        );
3179
3180        // healthy_agent_count reflects exclusion
3181        assert_eq!(router.healthy_agent_count("medium"), 0);
3182
3183        // Cleanup
3184        clear_agent_degraded("test_degraded_routing");
3185    }
3186
3187    #[serial(cooldown_state)]
3188    #[test]
3189    fn healthy_agents_skips_degraded_but_keeps_healthy() {
3190        let mut config = RouterConfig::default();
3191        for agent in &["agent_healthy", "agent_degraded"] {
3192            config
3193                .model_map
3194                .entry("medium".to_string())
3195                .or_default()
3196                .insert(agent.to_string(), vec!["model-x".to_string()]);
3197        }
3198
3199        let mut router = Router::new(config);
3200        router.available_agents = vec!["agent_healthy".to_string(), "agent_degraded".to_string()];
3201
3202        mark_agent_degraded("agent_degraded");
3203
3204        let candidates = router.available_agents_for_complexity("medium");
3205        assert_eq!(candidates, vec!["agent_healthy".to_string()]);
3206        assert_eq!(router.healthy_agent_count("medium"), 1);
3207
3208        // Cleanup
3209        clear_agent_degraded("agent_degraded");
3210    }
3211
3212    #[serial(cooldown_state)]
3213    #[tokio::test]
3214    async fn refresh_health_marks_degraded_from_rate_limits() {
3215        crate::engine::cooldown::reset_global_state().await;
3216        let store = test_store().await;
3217        let agent = "test_refresh_health_agent";
3218
3219        let mut config = RouterConfig::default();
3220        config
3221            .model_map
3222            .entry("medium".to_string())
3223            .or_default()
3224            .insert(agent.to_string(), vec!["model-z".to_string()]);
3225
3226        let mut router = Router::new(config);
3227        router.available_agents = vec![agent.to_string()];
3228
3229        // Insert rate limit events exceeding default threshold (3)
3230        for _ in 0..4 {
3231            store.record_rate_limit(agent, "rate", None).await.unwrap();
3232        }
3233
3234        router.refresh_health(&store).await;
3235
3236        assert!(
3237            is_agent_degraded(agent),
3238            "agent should be degraded after exceeding rate limit threshold"
3239        );
3240        assert!(
3241            !router.agent_is_routable(agent, "medium"),
3242            "degraded agent should not be routable"
3243        );
3244
3245        // Cleanup
3246        clear_agent_degraded(agent);
3247    }
3248
3249    // ---- route_with_llm candidate filter: agents with all models cooled (#3509) ----
3250
3251    #[serial(cooldown_state)]
3252    #[tokio::test]
3253    async fn agent_has_any_available_model_false_when_only_model_cooled() {
3254        crate::engine::cooldown::reset_global_state().await;
3255        let mut config = RouterConfig::default();
3256        // Agent's only model, shared across every complexity tier it resolves to.
3257        for tier in ["simple", "medium", "complex", "review"] {
3258            config
3259                .model_map
3260                .entry(tier.to_string())
3261                .or_default()
3262                .insert(
3263                    "test_all_cooled".to_string(),
3264                    vec!["only-model".to_string()],
3265                );
3266        }
3267
3268        let mut router = Router::new(config);
3269        router.available_agents = vec!["test_all_cooled".to_string()];
3270
3271        assert!(router.agent_has_any_available_model("test_all_cooled"));
3272
3273        record_model_failure("test_all_cooled", "only-model").await;
3274        assert!(is_model_in_cooldown("test_all_cooled", "only-model"));
3275
3276        assert!(
3277            !router.agent_has_any_available_model("test_all_cooled"),
3278            "agent with its only model cooled across every tier should have no available model"
3279        );
3280    }
3281
3282    #[serial(cooldown_state)]
3283    #[tokio::test]
3284    async fn llm_candidate_excludes_agent_with_all_models_cooled() {
3285        // Regression test for #3509: route_with_llm's candidate filter must exclude an
3286        // agent that has no agent-level cooldown but whose only resolvable model (across
3287        // every complexity tier) is cooled, so it is never handed to the LLM classifier.
3288        crate::engine::cooldown::reset_global_state().await;
3289        let mut config = RouterConfig::default();
3290        for tier in ["simple", "medium", "complex", "review"] {
3291            config
3292                .model_map
3293                .entry(tier.to_string())
3294                .or_default()
3295                .insert("agent_cooled_model".to_string(), vec!["opus".to_string()]);
3296            config
3297                .model_map
3298                .get_mut(tier)
3299                .unwrap()
3300                .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3301        }
3302
3303        let mut router = Router::new(config);
3304        router.available_agents = vec![
3305            "agent_cooled_model".to_string(),
3306            "agent_healthy".to_string(),
3307        ];
3308
3309        record_model_failure("agent_cooled_model", "opus").await;
3310        assert!(is_model_in_cooldown("agent_cooled_model", "opus"));
3311        // No agent-level cooldown — the old agent-only filter would still offer it.
3312        assert!(!is_agent_in_cooldown("agent_cooled_model"));
3313
3314        let uncooled_agents: Vec<String> = router
3315            .available_agents
3316            .iter()
3317            .filter(|a| {
3318                !is_agent_in_cooldown(a)
3319                    && !is_agent_degraded(a)
3320                    && router.agent_has_available_model_for_coding_tiers(a)
3321            })
3322            .cloned()
3323            .collect();
3324
3325        assert_eq!(
3326            uncooled_agents,
3327            vec!["agent_healthy".to_string()],
3328            "agent whose only model is cooled must be excluded from the LLM candidate list"
3329        );
3330    }
3331
3332    #[serial(cooldown_state)]
3333    #[tokio::test]
3334    async fn llm_candidate_excludes_agent_with_only_simple_tier_uncooled() {
3335        // Regression test for #3521: an agent whose only healthy tier is `simple`
3336        // (its `medium`/`complex` tiers resolve to the same cooled model) must be
3337        // excluded from the LLM classification candidate list, otherwise the
3338        // classifier can pick it for a `medium`/`complex` task it can't serve,
3339        // wasting the classification call. Mirrors the real-world shape where
3340        // `simple` → sonnet (uncooled) and `medium`/`complex` → opus (cooled).
3341        crate::engine::cooldown::reset_global_state().await;
3342        let mut config = RouterConfig::default();
3343        config
3344            .model_map
3345            .entry("simple".to_string())
3346            .or_default()
3347            .insert("minimax_like".to_string(), vec!["sonnet".to_string()]);
3348        config
3349            .model_map
3350            .entry("medium".to_string())
3351            .or_default()
3352            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3353        config
3354            .model_map
3355            .entry("complex".to_string())
3356            .or_default()
3357            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3358        // review must be irrelevant to the coding-tier filter
3359        config
3360            .model_map
3361            .entry("review".to_string())
3362            .or_default()
3363            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3364        for tier in ["simple", "medium", "complex", "review"] {
3365            config
3366                .model_map
3367                .get_mut(tier)
3368                .unwrap()
3369                .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3370        }
3371
3372        let mut router = Router::new(config);
3373        router.available_agents = vec!["minimax_like".to_string(), "agent_healthy".to_string()];
3374
3375        record_model_failure("minimax_like", "opus").await;
3376        assert!(is_model_in_cooldown("minimax_like", "opus"));
3377        // No agent-level cooldown, and the simple tier is healthy, so the loose
3378        // "any tier" check still passes — this is exactly the leak #3511 missed.
3379        assert!(!is_agent_in_cooldown("minimax_like"));
3380        assert!(router.agent_has_any_available_model("minimax_like"));
3381        assert!(!router.agent_has_available_model_for_coding_tiers("minimax_like"));
3382
3383        let uncooled_agents: Vec<String> = router
3384            .available_agents
3385            .iter()
3386            .filter(|a| {
3387                !is_agent_in_cooldown(a)
3388                    && !is_agent_degraded(a)
3389                    && router.agent_has_available_model_for_coding_tiers(a)
3390            })
3391            .cloned()
3392            .collect();
3393
3394        assert_eq!(
3395            uncooled_agents,
3396            vec!["agent_healthy".to_string()],
3397            "agent with only its simple tier uncooled must be excluded from the LLM candidate list"
3398        );
3399    }
3400}