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