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        // Hold the task before any classification, label override, or fallback.
689        // A waiting task must not increment route_attempts.
690        match store.resolve_id_by_external(repo, &task.id.0).await {
691            Ok(Some(id)) => match store.block_claim_if_waiting(id).await {
692                Ok(Some(blockers)) => {
693                    return Err(crate::store::WaitingOnDependency { blockers }.into());
694                }
695                Ok(None) => {}
696                Err(err) => return Err(err),
697            },
698            Ok(None) => {}
699            Err(err) => return Err(err),
700        }
701
702        loop {
703            // 1. Check store agent field first (set by failover, authoritative over labels)
704            // Use a lightweight lookup to avoid deserializing the full Task row when
705            // only the `agent` column is needed. This avoids two full-row loads
706            // per routing pass (resolve_task_id + get) and significantly reduces
707            // CPU work on busy instances.
708            let store_agent = match store.get_agent_by_external_id(repo, &task.id.0).await {
709                Ok(Some(agent)) => {
710                    // Router enforces that the agent string matches a configured agent
711                    if !agent.is_empty() && self.config.agents.iter().any(|cfg| cfg == &agent) {
712                        Some(agent)
713                    } else {
714                        None
715                    }
716                }
717                Ok(None) => None,
718                Err(e) => {
719                    tracing::warn!(
720                        repo,
721                        task_id = %task.id.0,
722                        err = %e,
723                        "failed to fetch stored agent, falling back to label/LLM routing"
724                    );
725                    None
726                }
727            };
728
729            // 2. Fall back to explicit agent label
730            let resolved_agent = store_agent.or_else(|| {
731                strategies::extract_agent_from_labels(&self.config.agents, &task.labels)
732            });
733
734            if let Some(agent) = resolved_agent {
735                let complexity = strategies::extract_complexity_from_labels(&task.labels);
736                // If agent appears routable (not in agent-wide cooldown and has
737                // available models or no model_map configured), check whether
738                // we actually have a concrete model to dispatch. If the
739                // resolved label maps to no model (None), we fall through to
740                // the standard routing logic so the router LLM can pick a
741                // proper agent/model, instead of dispatching with an empty
742                // model which causes some agents (e.g. opencode) to exit
743                // silently.
744                if self.agent_is_routable(&agent, &complexity) {
745                    let model = self
746                        .config
747                        .model_for_complexity(&agent, &complexity, &task.id.0);
748
749                    // Only dispatch label overrides when we have a concrete
750                    // model to send to the executor. Dispatching with an
751                    // empty model led to silent exits for some agents
752                    // (notably opencode). If no concrete model is available
753                    // for the labeled agent, fall through to the standard
754                    // routing logic so the LLM or round-robin can pick an
755                    // appropriate agent/model.
756                    if model.is_some() {
757                        tracing::debug!(
758                            task_id = %task.id.0,
759                            agent = %agent,
760                            complexity = %complexity,
761                            "routed via label"
762                        );
763                        let mut result = RouteResult::from_config(
764                            agent.clone(),
765                            complexity.clone(),
766                            format!("label agent:{agent}"),
767                            &self.config,
768                            &task.id.0,
769                        );
770                        // Label routing uses agent-specific role and the
771                        // resolved model (may differ from from_config's default).
772                        result.profile.role = format!("{} specialist", agent);
773                        result.model = model.clone();
774                        self.log_route_activity(store, repo, &task.id.0, &result, None)
775                            .await;
776                        return Ok(result);
777                    } else {
778                        // No concrete model found for the labeled agent — do
779                        // not honor the label override. Fall through to the
780                        // standard routing logic so another agent or the LLM
781                        // can be chosen with a valid model.
782                        tracing::warn!(task_id = %task.id.0, agent = %agent, "label agent has no available model — falling through to LLM routing");
783                    }
784                }
785
786                let candidates = self.available_agents_for_complexity(&complexity);
787                if candidates.is_empty() {
788                    self.wait_for_cooldown(Some(&complexity)).await?;
789                    continue;
790                }
791                // Label target is cooled or lacked model; fall through to standard routing.
792            }
793
794            let complexity = strategies::extract_complexity_from_labels(&task.labels);
795
796            // Check if any agents have available models for this complexity
797            let mut candidates = self.available_agents_for_complexity(&complexity);
798            if candidates.is_empty() {
799                self.wait_for_cooldown(Some(&complexity)).await?;
800                continue;
801            }
802
803            // Skip the no-code agent if one is recorded for this task (#2410).
804            // If all available agents are filtered out, we fall through to
805            // LLM routing with no constraints (LLM will pick the same agent but
806            // the response_handler will block it immediately via same-agent check).
807            if let Some(filtered) = self
808                .skip_no_code_agent(store, repo, &task.id.0, &candidates)
809                .await
810            {
811                candidates = filtered;
812            }
813
814            // 2. Weighted round-robin — capacity-based selection
815            if self.config.weighted_round_robin {
816                let result = strategies::route_via_weighted_round_robin(
817                    &candidates,
818                    &self.weights,
819                    &self.config,
820                    task,
821                    &mut self.last_agent,
822                )?;
823                self.log_route_activity(store, repo, &task.id.0, &result, None)
824                    .await;
825                return Ok(result);
826            }
827
828            // 3. Round-robin mode — use stateful round-robin
829            if self.config.mode == "round_robin" {
830                tracing::debug!(task_id = %task.id.0, "routing via round-robin mode");
831                let result = strategies::route_via_round_robin_stateful(
832                    &candidates,
833                    &self.config,
834                    task,
835                    &mut self.rr_index,
836                    &mut self.last_agent,
837                )?;
838                self.log_route_activity(store, repo, &task.id.0, &result, None)
839                    .await;
840                return Ok(result);
841            }
842
843            // 4. Local Ollama routing
844            if self.config.mode == "local" {
845                tracing::debug!(task_id = %task.id.0, ollama_url = %self.config.ollama_url, "routing via local Ollama");
846                let ollama_router = self
847                    .ollama_router
848                    .as_ref()
849                    .expect("ollama_router must be initialized in Router::new when mode=local");
850                match ollama_router.route(task, &self.config).await {
851                    Ok(result) => {
852                        self.log_route_activity(store, repo, &task.id.0, &result, None)
853                            .await;
854                        return Ok(result);
855                    }
856                    Err(e) => {
857                        tracing::warn!(
858                            task_id = %task.id.0,
859                            error = %e,
860                            "Ollama routing failed, falling back to round-robin"
861                        );
862                        let result = strategies::route_via_round_robin_stateful(
863                            &candidates,
864                            &self.config,
865                            task,
866                            &mut self.rr_index,
867                            &mut self.last_agent,
868                        )?;
869                        self.log_route_activity(store, repo, &task.id.0, &result, None)
870                            .await;
871                        return Ok(result);
872                    }
873                }
874            }
875
876            // 5. LLM-based routing with retry tracking
877            let route_attempts = self.get_route_attempts(&task.id.0, store, repo).await;
878
879            if route_attempts >= self.config.max_route_attempts {
880                tracing::warn!(
881                    task_id = %task.id.0,
882                    attempts = route_attempts,
883                    max = self.config.max_route_attempts,
884                    "max LLM route attempts reached, falling back to round-robin"
885                );
886                let candidates = self.available_agents_for_complexity(&complexity);
887                if candidates.is_empty() {
888                    self.wait_for_cooldown(Some(&complexity)).await?;
889                    continue;
890                }
891                let result = strategies::route_via_round_robin_stateful(
892                    &candidates,
893                    &self.config,
894                    task,
895                    &mut self.rr_index,
896                    &mut self.last_agent,
897                )?;
898                self.log_route_activity(store, repo, &task.id.0, &result, None)
899                    .await;
900                return Ok(result);
901            }
902
903            // Log routing start (before await)
904            tracing::debug!(task_id = %task.id.0, "starting LLM routing");
905
906            match self.route_with_llm(task, repo).await {
907                Ok(result) => {
908                    let candidates = self.available_agents_for_complexity(&result.complexity);
909                    if candidates.is_empty() {
910                        self.wait_for_cooldown(Some(&result.complexity)).await?;
911                        continue;
912                    }
913
914                    // If the LLM selected the no-code agent, filter it out and
915                    // pick an alternative (#2410). Re-use the existing fallback logic.
916                    let no_code_agent =
917                         get_task_field_direct(store, repo, &task.id.0, "no_code_last_agent")
918                             .await
919                             .unwrap_or_else(|| {
920                                 tracing::warn!(task_id = %task.id.0, "no_code_last_agent DB read returned None — loop detection may be impaired");
921                                 String::new()
922                             });
923                    if result.agent == no_code_agent {
924                        // Pick the first agent that isn't the no-code agent.
925                        let fallback_agent = candidates
926                            .iter()
927                            .find(|a| a.as_str() != no_code_agent)
928                            .cloned()
929                            .unwrap_or_else(|| candidates[0].clone());
930                        if fallback_agent.as_str() == no_code_agent {
931                            tracing::warn!(
932                                task_id = %task.id.0,
933                                no_code_agent = %no_code_agent,
934                                complexity = %result.complexity,
935                                "all available agents are the no-code agent — waiting for another agent"
936                            );
937                            self.wait_for_cooldown(Some(&result.complexity)).await?;
938                            continue;
939                        }
940                        tracing::info!(
941                            task_id = %task.id.0,
942                            llm_selected = %result.agent,
943                            skipped = %no_code_agent,
944                            fallback = %fallback_agent,
945                            "LLM selected the no-code agent — routing to alternative"
946                        );
947                        let fallback_model = self.config.model_for_complexity(
948                            &fallback_agent,
949                            &result.complexity,
950                            &task.id.0,
951                        );
952
953                        self.set_route_attempts(&task.id.0, 0, store, repo).await;
954                        let result = RouteResult {
955                            agent: fallback_agent.clone(),
956                            model: fallback_model,
957                            complexity: result.complexity.clone(),
958                            estimate: result.estimate,
959                            reason: format!(
960                                "LLM selected no-code agent {}; routed to {}",
961                                result.agent, fallback_agent
962                            ),
963                            profile: result.profile.clone(),
964                            selected_skills: result.selected_skills.clone(),
965                            warning: Some(format!(
966                                "LLM selected no-code agent {}; used fallback {}",
967                                result.agent, fallback_agent
968                            )),
969                        };
970                        self.log_route_activity(store, repo, &task.id.0, &result, None)
971                            .await;
972                        return Ok(result);
973                    }
974
975                    let model_cooled = result
976                        .model
977                        .as_deref()
978                        .is_some_and(|model| is_model_in_cooldown(&result.agent, model));
979                    if !candidates.contains(&result.agent) || model_cooled {
980                        let fallback_agent = candidates
981                            .iter()
982                            .find(|agent| *agent != &result.agent)
983                            .cloned()
984                            .unwrap_or_else(|| candidates[0].clone());
985                        let fallback_model = self.config.model_for_complexity(
986                            &fallback_agent,
987                            &result.complexity,
988                            &task.id.0,
989                        );
990
991                        // If the fallback model is None and the fallback agent is the
992                        // same as the LLM-selected agent, it means all models for the
993                        // only available agent are cooled. In that case, wait for
994                        // cooldowns instead of returning a RouteResult with
995                        // model: None which would be dispatched incorrectly.
996                        if fallback_model.is_none() && 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(s) are cooled for the only available agent; waiting for cooldown"
1002                            );
1003                            self.wait_for_cooldown(Some(&result.complexity)).await?;
1004                            continue;
1005                        }
1006
1007                        // Distinguish logging and reasons when we actually switch
1008                        // agents vs when we keep the same agent but pick a
1009                        // different (non-cooled) model.
1010                        if fallback_agent == result.agent {
1011                            tracing::warn!(
1012                                task_id = %task.id.0,
1013                                agent = %result.agent,
1014                                complexity = %result.complexity,
1015                                "LLM-selected model was cooled; selecting an alternative model for the same agent"
1016                            );
1017                        } else {
1018                            tracing::warn!(
1019                                task_id = %task.id.0,
1020                                agent = %result.agent,
1021                                fallback = %fallback_agent,
1022                                complexity = %result.complexity,
1023                                "LLM selected cooled agent/model; rerouting to available agent"
1024                            );
1025                        }
1026
1027                        // Reset attempts on success
1028                        self.set_route_attempts(&task.id.0, 0, store, repo).await;
1029
1030                        let reason = if fallback_agent == result.agent {
1031                            format!(
1032                                "LLM selected cooled model; using same agent {} with different model",
1033                                fallback_agent
1034                            )
1035                        } else {
1036                            format!(
1037                                "LLM selected cooled agent/model; rerouted to {}",
1038                                fallback_agent
1039                            )
1040                        };
1041
1042                        let result = RouteResult {
1043                            agent: fallback_agent.clone(),
1044                            model: fallback_model,
1045                            complexity: result.complexity.clone(),
1046                            estimate: result.estimate,
1047                            reason: reason.clone(),
1048                            profile: result.profile.clone(),
1049                            selected_skills: result.selected_skills.clone(),
1050                            warning: Some(if fallback_agent == result.agent {
1051                                "LLM-selected model was cooled; selected alternative model for same agent"
1052                                    .to_string()
1053                            } else {
1054                                "LLM-selected agent/model was cooled; rerouted to available agent"
1055                                    .to_string()
1056                            }),
1057                        };
1058                        self.log_route_activity(store, repo, &task.id.0, &result, None)
1059                            .await;
1060                        return Ok(result);
1061                    }
1062
1063                    // Reset attempts on success
1064                    self.set_route_attempts(&task.id.0, 0, store, repo).await;
1065                    tracing::info!(
1066                        task_id = %task.id.0,
1067                        agent = %result.agent,
1068                        complexity = %result.complexity,
1069                        "routed via LLM"
1070                    );
1071                    self.log_route_activity(store, repo, &task.id.0, &result, None)
1072                        .await;
1073                    return Ok(result);
1074                }
1075                Err(e) => {
1076                    if let Some(err) = e.downcast_ref::<AllAgentsCooledError>() {
1077                        let scope = err.scope();
1078                        tracing::warn!(scope = %scope, "router cooldown gate tripped");
1079                        // "router pool" scope means all router LLM pool entries were pre-cooled.
1080                        // Pass None so wait_for_cooldown queries all agent+model cooldowns generically
1081                        // (no complexity-specific pool lookup, which would fail for "router pool").
1082                        let scope_opt = match scope {
1083                            "all agents" | "router pool" => None,
1084                            _ => Some(scope),
1085                        };
1086                        self.wait_for_cooldown(scope_opt).await?;
1087                        continue;
1088                    }
1089
1090                    let new_attempts = route_attempts + 1;
1091                    self.set_route_attempts(&task.id.0, new_attempts, store, repo)
1092                        .await;
1093                    tracing::warn!(
1094                        task_id = %task.id.0,
1095                        error = %e,
1096                        error_chain = ?e,
1097                        attempt = new_attempts,
1098                        max = self.config.max_route_attempts,
1099                        "LLM routing failed"
1100                    );
1101
1102                    if new_attempts >= self.config.max_route_attempts {
1103                        tracing::info!(
1104                            task_id = %task.id.0,
1105                            "falling back to round-robin after {} failed attempts",
1106                            new_attempts
1107                        );
1108                        let candidates = self.available_agents_for_complexity(&complexity);
1109                        if candidates.is_empty() {
1110                            self.wait_for_cooldown(Some(&complexity)).await?;
1111                            continue;
1112                        }
1113                        let result = strategies::route_via_round_robin_stateful(
1114                            &candidates,
1115                            &self.config,
1116                            task,
1117                            &mut self.rr_index,
1118                            &mut self.last_agent,
1119                        )?;
1120                        self.log_route_activity(store, repo, &task.id.0, &result, None)
1121                            .await;
1122                        return Ok(result);
1123                    }
1124
1125                    let candidates = self.available_agents_for_complexity(&complexity);
1126                    if candidates.is_empty() {
1127                        self.wait_for_cooldown(Some(&complexity)).await?;
1128                        continue;
1129                    }
1130                    let result = strategies::route_via_fallback(
1131                        &candidates,
1132                        &self.config,
1133                        task,
1134                        Some(&self.weights),
1135                    )?;
1136                    self.log_route_activity(store, repo, &task.id.0, &result, None)
1137                        .await;
1138                    return Ok(result);
1139                }
1140            }
1141        }
1142    }
1143
1144    /// Get the number of LLM routing attempts for a task from the store.
1145    async fn get_route_attempts(
1146        &self,
1147        task_id: &str,
1148        store: &std::sync::Arc<TaskStore>,
1149        repo: &str,
1150    ) -> u32 {
1151        match store.resolve_task_id(repo, task_id).await {
1152            Ok(Some(store_id)) => store
1153                .get(store_id)
1154                .await
1155                .map(|t| t.route_attempts as u32)
1156                .unwrap_or(0),
1157            _ => 0,
1158        }
1159    }
1160
1161    /// Set the number of LLM routing attempts for a task in the store.
1162    async fn set_route_attempts(
1163        &self,
1164        task_id: &str,
1165        attempts: u32,
1166        store: &std::sync::Arc<TaskStore>,
1167        repo: &str,
1168    ) {
1169        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1170            if let Err(e) = store
1171                .set_fields(store_id, &[("route_attempts", serde_json::json!(attempts))])
1172                .await
1173            {
1174                tracing::warn!(task_id, error = %e, "failed to store route_attempts");
1175            }
1176        }
1177    }
1178
1179    /// Log a route event to task activity timeline.
1180    async fn log_route_activity(
1181        &self,
1182        store: &std::sync::Arc<TaskStore>,
1183        repo: &str,
1184        task_id: &str,
1185        result: &RouteResult,
1186        from_agent: Option<&str>,
1187    ) {
1188        let details = serde_json::json!({
1189            "reason": result.reason,
1190            "complexity": result.complexity,
1191            "skills": result.selected_skills,
1192            "role": result.profile.role,
1193            "warning": result.warning,
1194        });
1195        let event_type = if from_agent.is_some() {
1196            "rerouted"
1197        } else {
1198            "routed"
1199        };
1200        store_log_activity(
1201            &Some(Arc::clone(store)),
1202            repo,
1203            task_id,
1204            event_type,
1205            None,
1206            Some("routed"),
1207            Some(&result.agent),
1208            result.model.as_deref(),
1209            Some(&details),
1210        )
1211        .await;
1212    }
1213
1214    /// Route using LLM classification with pool round-robin.
1215    ///
1216    /// Iterates through `router_pool` (skipping cooled entries) until one succeeds.
1217    /// On full exhaustion, tries the configured fallback. If the fallback also fails,
1218    /// returns the last error (the caller falls back to round-robin agent selection).
1219    async fn route_with_llm(
1220        &mut self,
1221        task: &ExternalTask,
1222        repo: &str,
1223    ) -> anyhow::Result<RouteResult> {
1224        // Filter out cooled and degraded agents, and agents without an available
1225        // model for the coding tiers (`medium`/`complex`) that real tasks are
1226        // classified into, so the LLM only sees candidates that could actually
1227        // be dispatched for whatever complexity it picks. An agent whose only
1228        // healthy tier is `simple` is excluded too: the classifier can't be
1229        // constrained to `simple`, so leaving such an agent in would waste the
1230        // call whenever it picks `medium`/`complex` (issue #3521).
1231        // Fall back to the full list if all agents are cooled/degraded.
1232        // Cloned to avoid borrow conflict with &mut self in the loop.
1233        let uncooled_agents: Vec<String> = self
1234            .available_agents
1235            .iter()
1236            .filter(|a| {
1237                !is_agent_in_cooldown(a)
1238                    && !is_agent_degraded(a)
1239                    && self.agent_has_available_model_for_coding_tiers(a)
1240            })
1241            .cloned()
1242            .collect();
1243        if uncooled_agents.is_empty() {
1244            return Err(AllAgentsCooledError::new("all agents".to_string(), None).into());
1245        }
1246        let llm_agents = uncooled_agents;
1247
1248        let pool = self.router_pool.clone();
1249        let n = pool.len();
1250        let start = self.pool_index;
1251        let mut last_err: Option<anyhow::Error> = None;
1252        let mut attempted_any = false;
1253
1254        // Try pool entries in round-robin order, skipping cooled ones
1255        for i in 0..n {
1256            let idx = (start + i) % n;
1257            let (agent, model) = &pool[idx];
1258            let model_str = model.as_str();
1259
1260            if crate::engine::runner::response::is_model_in_cooldown(agent, model_str) {
1261                tracing::debug!(agent, model = model_str, "pool entry on cooldown, skipping");
1262                continue;
1263            }
1264
1265            // At least one pool entry was available to attempt
1266            attempted_any = true;
1267
1268            let model_opt = if model_str.is_empty() {
1269                None
1270            } else {
1271                Some(model_str)
1272            };
1273
1274            match self
1275                .llm_router
1276                .route_with_llm_using(
1277                    task,
1278                    &llm_agents,
1279                    &self.config,
1280                    &mut self.last_agent,
1281                    repo,
1282                    agent,
1283                    model_opt,
1284                )
1285                .await
1286            {
1287                Ok(result) => {
1288                    // Advance index so the next call starts at the next pool entry
1289                    self.advance_pool_index_after_attempt(idx, n);
1290                    tracing::debug!(
1291                        agent,
1292                        model = model_str,
1293                        pool_idx = idx,
1294                        "router LLM pool entry succeeded"
1295                    );
1296                    record_agent_success(agent, model_str).await;
1297                    return Ok(result);
1298                }
1299                Err(e) => {
1300                    let err_msg = e.to_string();
1301                    let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1302                    if is_timeout {
1303                        // Advance pool index so the next tick starts at the next entry,
1304                        // then return immediately. Continuing to try further pool entries
1305                        // in the same tick would accumulate multiple timeout windows and
1306                        // stall the tick loop for minutes (issue #3187).
1307                        //
1308                        // Also record a standard cooldown, same as the non-timeout branch,
1309                        // so a chronically-timing-out entry is skipped by is_model_in_cooldown
1310                        // on future ticks instead of burning the full timeout window again
1311                        // every time round-robin reaches it (issue #3422).
1312                        self.advance_pool_index_after_attempt(idx, n);
1313                        tracing::warn!(
1314                            agent,
1315                            model = model_str,
1316                            next_pool_idx = self.pool_index,
1317                            "router LLM pool entry timed out — recording cooldown, will retry next tick"
1318                        );
1319                        crate::engine::runner::response::record_model_failure(agent, model_str)
1320                            .await;
1321                        return Err(e);
1322                    } else {
1323                        tracing::warn!(
1324                            agent,
1325                            model = model_str,
1326                            error = %e,
1327                            "pool entry failed, recording cooldown and trying next"
1328                        );
1329                        crate::engine::runner::response::record_model_failure(agent, model_str)
1330                            .await;
1331                    }
1332                    last_err = Some(e);
1333                    self.advance_pool_index_after_attempt(idx, n);
1334                }
1335            }
1336        }
1337
1338        // If every pool entry was pre-cooled (nothing was attempted), do not
1339        // immediately exhaust the pool again — fail fast and let the tick loop
1340        // retry on the next cycle instead of cascading failures.
1341        // However, we should still try the fallback if configured.
1342        if !attempted_any {
1343            let now = chrono::Utc::now().timestamp();
1344            let earliest = self.earliest_pool_cooldown();
1345            if let Some(until) = earliest {
1346                // Pool entries exist but are all cooled - we'll try fallback below
1347                let remaining = until.saturating_sub(now);
1348                tracing::warn!(
1349                    remaining_secs = remaining,
1350                    pool = ?pool,
1351                    "all router LLM pool entries on cooldown — will try fallback"
1352                );
1353                // Continue to fallback logic instead of returning early
1354            } else {
1355                // No pool cooldowns found either — this means the pool was empty
1356                // or every entry was filtered out for some other reason.
1357                return Err(last_err
1358                    .unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")));
1359            }
1360        }
1361
1362        // All pool entries failed or were cooled — try the configured fallback
1363        let fallback = self.config.effective_fallback();
1364        let (fb_agent, fb_model) = parse_pool_entry(&fallback);
1365        let fb_model_str = fb_model.as_str();
1366
1367        // Only try fallback if it wasn't already in the pool (avoid double-try)
1368        let fallback_already_tried = pool.iter().any(|(a, m)| a == &fb_agent && m == &fb_model);
1369
1370        if !fallback_already_tried
1371            && !crate::engine::runner::response::is_model_in_cooldown(&fb_agent, fb_model_str)
1372        {
1373            tracing::info!(
1374                agent = %fb_agent,
1375                model = %fb_model_str,
1376                "all pool entries exhausted — trying fallback router LLM"
1377            );
1378            let fb_model_opt = if fb_model_str.is_empty() {
1379                None
1380            } else {
1381                Some(fb_model_str)
1382            };
1383            match self
1384                .llm_router
1385                .route_with_llm_using(
1386                    task,
1387                    &llm_agents,
1388                    &self.config,
1389                    &mut self.last_agent,
1390                    repo,
1391                    &fb_agent,
1392                    fb_model_opt,
1393                )
1394                .await
1395            {
1396                Ok(result) => {
1397                    record_agent_success(&fb_agent, fb_model_str).await;
1398                    return Ok(result);
1399                }
1400                Err(e) => {
1401                    let err_msg = e.to_string();
1402                    let is_timeout = err_msg.contains(TIMEOUT_PREFIX);
1403                    if is_timeout {
1404                        // Cool down on timeout like pool entries (#3422), else a
1405                        // chronically-timing-out fallback burns the full timeout window every tick (#3616)
1406                        crate::engine::runner::response::record_model_failure(
1407                            &fb_agent,
1408                            fb_model_str,
1409                        )
1410                        .await;
1411                        tracing::warn!(
1412                            agent = %fb_agent,
1413                            model = %fb_model_str,
1414                            "fallback router LLM timed out — returning immediately to unblock tick loop"
1415                        );
1416                        return Err(e);
1417                    } else {
1418                        tracing::warn!(
1419                            agent = %fb_agent,
1420                            model = %fb_model_str,
1421                            error = %e,
1422                            "fallback router LLM also failed"
1423                        );
1424                        crate::engine::runner::response::record_model_failure(
1425                            &fb_agent,
1426                            fb_model_str,
1427                        )
1428                        .await;
1429                    }
1430                    last_err = Some(e);
1431                }
1432            }
1433        }
1434
1435        Err(last_err.unwrap_or_else(|| anyhow::anyhow!("all router LLM pool entries exhausted")))
1436    }
1437
1438    /// Record a rate limit event for an agent, reducing its routing weight.
1439    ///
1440    /// Called by the engine when an agent returns a 429/rate limit error.
1441    pub fn record_rate_limit(&mut self, agent: &str) {
1442        self.weights.record_rate_limit(agent);
1443    }
1444
1445    /// Record a successful task completion, restoring agent weight.
1446    pub fn record_success(&mut self, agent: &str) {
1447        self.weights.record_success(agent);
1448    }
1449
1450    /// Tick weight recovery for all agents.
1451    ///
1452    /// Called periodically by the engine to gradually restore weights
1453    /// as rate limit windows expire.
1454    pub fn tick_weight_recovery(&mut self) {
1455        self.weights.tick_recovery();
1456    }
1457
1458    /// Store routing result in the task store.
1459    pub async fn store_route_result(
1460        &self,
1461        task_id: &str,
1462        result: &RouteResult,
1463        store: &std::sync::Arc<TaskStore>,
1464        repo: &str,
1465    ) -> anyhow::Result<()> {
1466        if let Ok(Some(store_id)) = store.resolve_task_id(repo, task_id).await {
1467            store
1468                .set_fields(
1469                    store_id,
1470                    &[
1471                        ("agent", serde_json::json!(result.agent)),
1472                        ("complexity", serde_json::json!(result.complexity)),
1473                        ("route_reason", serde_json::json!(result.reason)),
1474                        (
1475                            "model",
1476                            serde_json::json!(result.model.as_deref().unwrap_or("")),
1477                        ),
1478                    ],
1479                )
1480                .await?;
1481        }
1482        Ok(())
1483    }
1484}
1485
1486/// Retrieve routing result from the task store.
1487pub async fn get_route_result(
1488    store: &std::sync::Arc<TaskStore>,
1489    repo: &str,
1490    task_id: &str,
1491) -> Result<RouteResult, RouteResultError> {
1492    let store_id = store
1493        .resolve_task_id(repo, task_id)
1494        .await
1495        .map_err(RouteResultError::Other)?
1496        .ok_or_else(|| {
1497            RouteResultError::Other(anyhow::anyhow!("task {task_id} not found in store"))
1498        })?;
1499    let task = store.get(store_id).await.map_err(RouteResultError::Other)?;
1500
1501    let agent = task
1502        .agent
1503        .clone()
1504        .filter(|a| !a.is_empty())
1505        .ok_or_else(|| RouteResultError::NoAgent {
1506            task_id: task_id.to_string(),
1507        })?;
1508
1509    let complexity = if task.complexity.is_empty() {
1510        "medium".to_string()
1511    } else {
1512        task.complexity.clone()
1513    };
1514
1515    let reason = task.route_reason.clone();
1516    let model = task.model.clone().filter(|m| !m.is_empty());
1517
1518    // Validate that the stored model is valid for the resolved agent.
1519    // Agent-specific aliases (e.g. "opus") can leak across agents during
1520    // failover, so we verify against the router's model pools (#1604).
1521    let config = RouterConfig::from_config();
1522    let model = model.filter(|m| {
1523        let complexities = ["simple", "medium", "complex", "review"];
1524        // Collect all model pools configured for this agent across complexity tiers.
1525        let all_pools: Vec<Vec<String>> = complexities
1526            .iter()
1527            .filter_map(|comp| config.model_pool_for_complexity(&agent, comp))
1528            .collect();
1529        // If the agent has no model pools configured, we can't validate — pass through.
1530        if all_pools.is_empty() {
1531            return true;
1532        }
1533        let valid = all_pools.iter().any(|pool| pool.contains(m));
1534        if !valid {
1535            tracing::warn!(
1536                task_id,
1537                agent,
1538                model = %m,
1539                "discarding stale model: not in agent's model pools"
1540            );
1541        }
1542        valid
1543    });
1544
1545    // If a stale model was discarded, update the database to clear the model
1546    // and record a diagnostic last_error so task_runs.error is populated if
1547    // the subsequent run fails (fixes issue #2527).
1548    let original_model = task.model.clone().filter(|m| !m.is_empty());
1549    if original_model.is_some() && model.is_none() {
1550        let stale_model = original_model.as_deref().unwrap_or("unknown");
1551        let stale_err = format!("stale model discarded: {stale_model} not in {agent} model pools");
1552        if let Err(e) = store
1553            .set_fields(
1554                store_id,
1555                &[
1556                    ("model", serde_json::json!("")),
1557                    ("last_error", serde_json::json!(stale_err)),
1558                ],
1559            )
1560            .await
1561        {
1562            // Log the error so transient DB failures are visible and diagnosable.
1563            tracing::warn!(task_id = %task_id, error = %e, "failed to clear stale model from store — will retry on next dispatch");
1564        }
1565    }
1566
1567    Ok(RouteResult {
1568        agent,
1569        model,
1570        complexity,
1571        estimate: 0,
1572        reason,
1573        profile: AgentProfile::default(),
1574        selected_skills: vec![],
1575        warning: None,
1576    })
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581    use super::config::DEFAULT_AGENTS;
1582    use super::llm::LlmRouteResponse;
1583    use super::strategies;
1584    use super::weights::{
1585        AgentWeights, RateLimitState, DEFAULT_WEIGHT, MIN_WEIGHT, RATE_LIMIT_DECAY, RECOVERY_DELAY,
1586    };
1587    use super::*;
1588    use crate::backends::{ExternalId, ExternalTask};
1589    use crate::engine::cooldown::{
1590        clear_agent_degraded, is_agent_degraded, is_model_in_cooldown, mark_agent_degraded,
1591        record_model_failure, set_model_cooldown,
1592    };
1593    use crate::store::TaskStore;
1594    use serial_test::serial;
1595    use std::time::{Duration, Instant};
1596
1597    // Test-only delegates so tests can call router.parse_llm_response() and
1598    // router.check_routing_sanity() directly without referencing llm_router.
1599    impl Router {
1600        fn parse_llm_response(&self, response: &str) -> anyhow::Result<LlmRouteResponse> {
1601            self.llm_router.parse_llm_response("claude", response)
1602        }
1603
1604        fn check_routing_sanity(
1605            &self,
1606            task: &ExternalTask,
1607            agent: &str,
1608            profile: &AgentProfile,
1609        ) -> Option<String> {
1610            self.llm_router.check_routing_sanity(task, agent, profile)
1611        }
1612    }
1613
1614    async fn test_store() -> std::sync::Arc<TaskStore> {
1615        std::sync::Arc::new(TaskStore::open_memory().await.unwrap())
1616    }
1617
1618    fn create_test_task(id: &str, title: &str, labels: Vec<String>) -> ExternalTask {
1619        ExternalTask {
1620            id: ExternalId(id.to_string()),
1621            title: title.to_string(),
1622            body: "Test body".to_string(),
1623            state: "open".to_string(),
1624            labels,
1625            author: "test".to_string(),
1626            created_at: "2026-01-01T00:00:00Z".to_string(),
1627            updated_at: "2026-01-01T00:00:00Z".to_string(),
1628            url: format!("https://github.com/test/test/issues/{id}"),
1629        }
1630    }
1631
1632    #[serial(cooldown_state)]
1633    #[test]
1634    fn extract_agent_from_labels() {
1635        let mut config = RouterConfig::default();
1636        // Ensure a concrete model exists for the labeled agent so the label
1637        // override is honored by the router (routing requires a model).
1638        config
1639            .model_map
1640            .entry("medium".to_string())
1641            .or_default()
1642            .insert("claude".to_string(), vec!["haiku".to_string()]);
1643
1644        assert_eq!(
1645            strategies::extract_agent_from_labels(&config.agents, &["agent:claude".to_string()]),
1646            Some("claude".to_string())
1647        );
1648        assert_eq!(
1649            strategies::extract_agent_from_labels(&config.agents, &["agent:codex".to_string()]),
1650            Some("codex".to_string())
1651        );
1652        assert_eq!(
1653            strategies::extract_agent_from_labels(&config.agents, &["agent:opencode".to_string()]),
1654            Some("opencode".to_string())
1655        );
1656        assert_eq!(
1657            strategies::extract_agent_from_labels(&config.agents, &["status:new".to_string()]),
1658            None
1659        );
1660        // Verify kimi and minimax are recognized from labels
1661        assert_eq!(
1662            strategies::extract_agent_from_labels(&config.agents, &["agent:kimi".to_string()]),
1663            Some("kimi".to_string())
1664        );
1665        assert_eq!(
1666            strategies::extract_agent_from_labels(&config.agents, &["agent:minimax".to_string()]),
1667            Some("minimax".to_string())
1668        );
1669    }
1670
1671    #[serial(cooldown_state)]
1672    #[test]
1673    fn default_agents_constant() {
1674        assert_eq!(DEFAULT_AGENTS.len(), 7);
1675        assert!(DEFAULT_AGENTS.contains(&"claude"));
1676        assert!(DEFAULT_AGENTS.contains(&"kimi"));
1677        assert!(DEFAULT_AGENTS.contains(&"minimax"));
1678        assert!(DEFAULT_AGENTS.contains(&"glm"));
1679        assert!(DEFAULT_AGENTS.contains(&"grok"));
1680    }
1681
1682    #[serial(cooldown_state)]
1683    #[test]
1684    fn extract_complexity_from_labels() {
1685        assert_eq!(
1686            strategies::extract_complexity_from_labels(&["complexity:simple".to_string()]),
1687            "simple"
1688        );
1689        assert_eq!(
1690            strategies::extract_complexity_from_labels(&["complexity:medium".to_string()]),
1691            "medium"
1692        );
1693        assert_eq!(
1694            strategies::extract_complexity_from_labels(&["complexity:complex".to_string()]),
1695            "complex"
1696        );
1697        assert_eq!(
1698            strategies::extract_complexity_from_labels(&["status:new".to_string()]),
1699            "medium"
1700        );
1701    }
1702
1703    #[serial(cooldown_state)]
1704    #[test]
1705    fn route_result_serialization() {
1706        let result = RouteResult {
1707            agent: "claude".to_string(),
1708            model: Some("sonnet".to_string()),
1709            complexity: "medium".to_string(),
1710            estimate: 5,
1711            reason: "test".to_string(),
1712            profile: AgentProfile {
1713                role: "backend".to_string(),
1714                skills: vec!["rust".to_string()],
1715                tools: vec!["git".to_string()],
1716                constraints: vec![],
1717            },
1718            selected_skills: vec!["gh".to_string()],
1719            warning: None,
1720        };
1721
1722        let json = serde_json::to_string(&result).unwrap();
1723        assert!(json.contains("claude"));
1724        assert!(json.contains("sonnet"));
1725
1726        let deserialized: RouteResult = serde_json::from_str(&json).unwrap();
1727        assert_eq!(deserialized.agent, "claude");
1728        assert_eq!(deserialized.model, Some("sonnet".to_string()));
1729    }
1730
1731    #[serial(cooldown_state)]
1732    #[test]
1733    fn router_config_default() {
1734        let mut config = RouterConfig::default();
1735        // Ensure a concrete model exists for the labeled agent so the label
1736        // override is honored by the router (routing requires a model).
1737        config
1738            .model_map
1739            .entry("medium".to_string())
1740            .or_default()
1741            .insert("claude".to_string(), vec!["haiku".to_string()]);
1742
1743        assert_eq!(config.mode, "round_robin");
1744        assert_eq!(config.router_agent, "claude");
1745        assert_eq!(config.router_model, "haiku");
1746        assert_eq!(config.fallback_executor, "codex");
1747        assert_eq!(config.max_route_attempts, 3);
1748        assert!(!config.allowed_tools.is_empty());
1749        assert!(!config.default_skills.is_empty());
1750
1751        // Verify configurable agents list includes all 7 agents
1752        assert_eq!(config.agents.len(), 7);
1753        assert!(config.agents.contains(&"claude".to_string()));
1754        assert!(config.agents.contains(&"codex".to_string()));
1755        assert!(config.agents.contains(&"opencode".to_string()));
1756        assert!(config.agents.contains(&"kimi".to_string()));
1757        assert!(config.agents.contains(&"minimax".to_string()));
1758        assert!(config.agents.contains(&"glm".to_string()));
1759        assert!(config.agents.contains(&"grok".to_string()));
1760    }
1761
1762    #[serial(cooldown_state)]
1763    #[test]
1764    fn model_map_lookup_returns_none_without_config() {
1765        // Default config has no hardcoded models — config.yml is the source of truth.
1766        let config = RouterConfig::default();
1767        assert!(config
1768            .model_for_complexity("claude", "simple", "")
1769            .is_none());
1770        assert!(config
1771            .model_for_complexity("claude", "medium", "")
1772            .is_none());
1773        assert!(config
1774            .model_for_complexity("claude", "complex", "")
1775            .is_none());
1776        assert!(config
1777            .model_for_complexity("opencode", "review", "")
1778            .is_none());
1779    }
1780
1781    #[serial(cooldown_state)]
1782    #[test]
1783    fn model_map_lookup_returns_configured_value() {
1784        // When model_map is populated (from config), it is returned correctly.
1785        let mut config = RouterConfig::default();
1786        config
1787            .model_map
1788            .entry("simple".to_string())
1789            .or_default()
1790            .insert("claude".to_string(), vec!["haiku".to_string()]);
1791        assert_eq!(
1792            config.model_for_complexity("claude", "simple", ""),
1793            Some("haiku".to_string())
1794        );
1795    }
1796
1797    #[serial(cooldown_state)]
1798    #[tokio::test]
1799    async fn model_pool_selection_skips_cooled() {
1800        crate::engine::cooldown::reset_global_state().await;
1801        use std::collections::HashMap;
1802
1803        let mut config = RouterConfig::default();
1804        // Set up a two-model pool for opencode/simple
1805        config
1806            .model_map
1807            .entry("simple".to_string())
1808            .or_default()
1809            .insert(
1810                "opencode".to_string(),
1811                vec!["model-a".to_string(), "model-b".to_string()],
1812            );
1813
1814        // Before any cooldown, both models are candidates
1815        let m = config.model_for_complexity("opencode", "simple", "task-1");
1816        assert!(m == Some("model-a".to_string()) || m == Some("model-b".to_string()));
1817
1818        // Cool model-a
1819        record_model_failure("opencode", "model-a").await;
1820        assert!(is_model_in_cooldown("opencode", "model-a"));
1821
1822        // Now only model-b should be returned
1823        // (try many task_ids to rule out hash coincidence)
1824        for i in 0..20 {
1825            let result = config.model_for_complexity("opencode", "simple", &i.to_string());
1826            assert_eq!(
1827                result,
1828                Some("model-b".to_string()),
1829                "expected model-b when model-a is cooled, got {result:?} for task_id={i}"
1830            );
1831        }
1832
1833        // Cool model-b too — now all models are cooled
1834        record_model_failure("opencode", "model-b").await;
1835        // All cooled → return None so the caller can fall back to a different agent
1836        let fallback = config.model_for_complexity("opencode", "simple", "task-fallback");
1837        assert_eq!(fallback, None);
1838        let _ = HashMap::<(), ()>::new(); // suppress unused import lint
1839    }
1840
1841    #[serial(cooldown_state)]
1842    #[test]
1843    fn model_pool_single_string_backward_compat() {
1844        // Single-item pools behave identically to the old string format.
1845        // Manually insert a single-item pool and verify it is returned correctly.
1846        let mut config = RouterConfig::default();
1847        config
1848            .model_map
1849            .entry("simple".to_string())
1850            .or_default()
1851            .insert("claude".to_string(), vec!["haiku".to_string()]);
1852        let m = config.model_for_complexity("claude", "simple", "any-task");
1853        assert_eq!(m, Some("haiku".to_string()));
1854    }
1855
1856    #[serial(cooldown_state)]
1857    #[test]
1858    fn parse_llm_response_direct_json() {
1859        let config = RouterConfig::default();
1860        let router = Router::new(config);
1861
1862        let response = r#"{
1863            "executor": "claude",
1864            "complexity": "complex",
1865            "reason": "requires architecture analysis",
1866            "profile": {
1867                "role": "architect",
1868                "skills": ["rust", "design"],
1869                "tools": ["git", "rg"],
1870                "constraints": []
1871            },
1872            "selected_skills": ["gh"]
1873        }"#;
1874
1875        let parsed = router.parse_llm_response(response).unwrap();
1876        assert_eq!(parsed.executor, "claude");
1877        assert_eq!(parsed.complexity, "complex");
1878        assert_eq!(parsed.reason, "requires architecture analysis");
1879        assert_eq!(parsed.profile.role, "architect");
1880    }
1881
1882    #[serial(cooldown_state)]
1883    #[test]
1884    fn parse_llm_response_markdown_fenced() {
1885        let config = RouterConfig::default();
1886        let router = Router::new(config);
1887
1888        let response = r#"Here's my analysis:
1889
1890```json
1891{
1892    "executor": "codex",
1893    "complexity": "medium",
1894    "reason": "coding task",
1895    "profile": {
1896        "role": "developer",
1897        "skills": ["coding"],
1898        "tools": [],
1899        "constraints": []
1900    },
1901    "selected_skills": []
1902}
1903```
1904
1905Hope that helps!"#;
1906
1907        let parsed = router.parse_llm_response(response).unwrap();
1908        assert_eq!(parsed.executor, "codex");
1909        assert_eq!(parsed.complexity, "medium");
1910    }
1911
1912    #[serial(cooldown_state)]
1913    #[test]
1914    fn parse_llm_response_claude_envelope() {
1915        let config = RouterConfig::default();
1916        let router = Router::new(config);
1917
1918        // Claude --output-format json wraps the response in an envelope
1919        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}}"#;
1920
1921        let parsed = router.parse_llm_response(response).unwrap();
1922        assert_eq!(parsed.executor, "claude");
1923        assert_eq!(parsed.complexity, "complex");
1924        assert_eq!(parsed.reason, "requires deep analysis");
1925    }
1926
1927    #[serial(cooldown_state)]
1928    #[test]
1929    fn parse_llm_response_claude_envelope_with_code_block() {
1930        let config = RouterConfig::default();
1931        let router = Router::new(config);
1932
1933        // Claude may return the JSON inside a code block within the envelope
1934        let inner = "```json\n{\"executor\":\"codex\",\"complexity\":\"simple\",\"reason\":\"simple fix\",\"profile\":{\"role\":\"developer\",\"skills\":[],\"tools\":[],\"constraints\":[]},\"selected_skills\":[]}\n```";
1935        let envelope = serde_json::json!({
1936            "type": "result",
1937            "subtype": "success",
1938            "is_error": false,
1939            "result": inner,
1940            "usage": {"input_tokens": 50, "output_tokens": 30}
1941        });
1942
1943        let parsed = router.parse_llm_response(&envelope.to_string()).unwrap();
1944        assert_eq!(parsed.executor, "codex");
1945        assert_eq!(parsed.complexity, "simple");
1946    }
1947
1948    #[serial(cooldown_state)]
1949    #[test]
1950    fn parse_llm_response_claude_envelope_error() {
1951        let config = RouterConfig::default();
1952        let router = Router::new(config);
1953
1954        let response = r#"{"type":"result","subtype":"error","is_error":true,"result":"rate limit exceeded","usage":{}}"#;
1955
1956        let err = router.parse_llm_response(response).unwrap_err();
1957        assert!(err.to_string().contains("error"), "got: {err}");
1958    }
1959
1960    #[serial(cooldown_state)]
1961    #[test]
1962    fn parse_llm_response_empty() {
1963        let config = RouterConfig::default();
1964        let router = Router::new(config);
1965
1966        let err = router.parse_llm_response("").unwrap_err();
1967        assert!(err.to_string().contains("empty"), "got: {err}");
1968    }
1969
1970    /// Claude envelope where result is a JSON object, not a string.
1971    /// Some Claude versions may return the result as a nested object.
1972    #[serial(cooldown_state)]
1973    #[test]
1974    fn parse_llm_response_claude_envelope_result_as_object() {
1975        let config = RouterConfig::default();
1976        let router = Router::new(config);
1977
1978        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}}"#;
1979
1980        let parsed = router.parse_llm_response(response);
1981        assert!(
1982            parsed.is_ok(),
1983            "should handle result-as-object: {}",
1984            parsed.unwrap_err()
1985        );
1986        assert_eq!(parsed.unwrap().executor, "claude");
1987    }
1988
1989    /// LLM returns "agent" instead of "executor" — common hallucination.
1990    #[serial(cooldown_state)]
1991    #[test]
1992    fn parse_llm_response_agent_instead_of_executor() {
1993        let config = RouterConfig::default();
1994        let router = Router::new(config);
1995
1996        let response = r#"{"agent":"claude","complexity":"medium","reason":"multi-file feature","profile":{"role":"developer","skills":[],"tools":[],"constraints":[]},"selected_skills":[]}"#;
1997
1998        let parsed = router.parse_llm_response(response);
1999        assert!(
2000            parsed.is_ok(),
2001            "should accept 'agent' alias: {}",
2002            parsed.unwrap_err()
2003        );
2004        assert_eq!(parsed.unwrap().executor, "claude");
2005    }
2006
2007    /// LLM returns minimal JSON without profile or reason.
2008    #[serial(cooldown_state)]
2009    #[test]
2010    fn parse_llm_response_minimal_json() {
2011        let config = RouterConfig::default();
2012        let router = Router::new(config);
2013
2014        let response = r#"{"executor":"codex","complexity":"simple"}"#;
2015
2016        let parsed = router.parse_llm_response(response);
2017        assert!(
2018            parsed.is_ok(),
2019            "should accept minimal JSON: {}",
2020            parsed.unwrap_err()
2021        );
2022        assert_eq!(parsed.unwrap().executor, "codex");
2023    }
2024
2025    // ── Real fixture tests: route prompt → response → parsed result ──
2026
2027    /// Real Claude envelope with result as escaped JSON string.
2028    #[serial(cooldown_state)]
2029    #[test]
2030    fn fixture_route_response_string() {
2031        let config = RouterConfig::default();
2032        let router = Router::new(config);
2033
2034        let response = include_str!("../../../tests/fixtures/route-response-string.json");
2035        let parsed = router.parse_llm_response(response).unwrap();
2036        assert_eq!(parsed.executor, "codex");
2037        assert_eq!(parsed.complexity, "medium");
2038        assert!(!parsed.reason.is_empty());
2039        assert!(!parsed.profile.role.is_empty());
2040    }
2041
2042    /// Real Claude envelope with result as JSON object (not string).
2043    /// This was the root cause of all router failures on 2026-02-27.
2044    #[serial(cooldown_state)]
2045    #[test]
2046    fn fixture_route_response_object() {
2047        let config = RouterConfig::default();
2048        let router = Router::new(config);
2049
2050        let response = include_str!("../../../tests/fixtures/route-response-object.json");
2051        let parsed = router.parse_llm_response(response).unwrap();
2052        assert_eq!(parsed.executor, "claude");
2053        assert_eq!(parsed.complexity, "complex");
2054    }
2055
2056    /// Real Claude envelope with result as string containing markdown + JSON.
2057    #[serial(cooldown_state)]
2058    #[test]
2059    fn fixture_route_response_markdown() {
2060        let config = RouterConfig::default();
2061        let router = Router::new(config);
2062
2063        let response = include_str!("../../../tests/fixtures/route-response-markdown.json");
2064        let parsed = router.parse_llm_response(response).unwrap();
2065        assert_eq!(parsed.executor, "codex");
2066        assert_eq!(parsed.complexity, "medium");
2067    }
2068
2069    /// Claude envelope with escaped JSON string containing newlines and markdown.
2070    #[serial(cooldown_state)]
2071    #[test]
2072    fn parse_llm_response_claude_envelope_with_prose() {
2073        let config = RouterConfig::default();
2074        let router = Router::new(config);
2075
2076        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.";
2077        let envelope = serde_json::json!({
2078            "type": "result",
2079            "subtype": "success",
2080            "is_error": false,
2081            "result": inner,
2082            "usage": {"input_tokens": 100, "output_tokens": 80}
2083        });
2084
2085        let parsed = router.parse_llm_response(&envelope.to_string());
2086        assert!(
2087            parsed.is_ok(),
2088            "should handle prose + code block in envelope: {}",
2089            parsed.unwrap_err()
2090        );
2091        assert_eq!(parsed.unwrap().executor, "codex");
2092    }
2093
2094    #[serial(cooldown_state)]
2095    #[tokio::test]
2096    async fn route_round_robin_basic() {
2097        crate::engine::cooldown::reset_global_state().await;
2098        // Force at least one agent to be available for testing
2099        // In real usage, discover_agents finds installed CLIs
2100        let config = RouterConfig {
2101            mode: "round_robin".to_string(),
2102            ..Default::default()
2103        };
2104
2105        // Create router with mock available agents
2106        let agents = vec!["claude".to_string(), "codex".to_string()];
2107        let mut weights = AgentWeights::default();
2108        weights.ensure_agents(&agents);
2109        let router = Router {
2110            config,
2111            available_agents: agents,
2112            weights,
2113            llm_router: std::sync::Arc::new(LlmRouter::new()),
2114            ollama_router: None,
2115            rr_index: 0,
2116            last_agent: None,
2117            review_rr_index: 0,
2118            router_pool: vec![],
2119            pool_index: 0,
2120        };
2121
2122        let task = create_test_task("1", "Test task", vec![]);
2123        let result =
2124            strategies::route_via_round_robin(&router.available_agents, &router.config, &task)
2125                .unwrap();
2126
2127        // Task 1 % 2 agents = agent at index 1 = codex
2128        assert_eq!(result.agent, "codex");
2129        assert_eq!(result.reason, "round_robin (task 1 % 2 agents)");
2130    }
2131
2132    #[serial(cooldown_state)]
2133    #[tokio::test]
2134    async fn route_uses_label_override() {
2135        crate::engine::cooldown::reset_global_state().await;
2136        let mut config = RouterConfig::default();
2137        // Ensure a concrete model exists so the label override can be honored
2138        // in the test environment where no external config.yml is present.
2139        config
2140            .model_map
2141            .entry("medium".to_string())
2142            .or_default()
2143            .insert("claude".to_string(), vec!["haiku".to_string()]);
2144        let agents = vec!["claude".to_string(), "codex".to_string()];
2145        let mut weights = AgentWeights::default();
2146        weights.ensure_agents(&agents);
2147        let mut router = Router {
2148            config,
2149            available_agents: agents,
2150            weights,
2151            llm_router: std::sync::Arc::new(LlmRouter::new()),
2152            ollama_router: None,
2153            rr_index: 0,
2154            last_agent: None,
2155            review_rr_index: 0,
2156            router_pool: vec![],
2157            pool_index: 0,
2158        };
2159
2160        let task = create_test_task("1", "Test", vec!["agent:claude".to_string()]);
2161        let store = test_store().await;
2162
2163        // Should use label override, not LLM
2164        let result = router.route(&task, &store, "test/repo").await.unwrap();
2165        assert_eq!(result.agent, "claude");
2166        assert!(result.reason.contains("label"));
2167    }
2168
2169    #[serial(cooldown_state)]
2170    #[test]
2171    fn check_routing_sanity_warnings() {
2172        let config = RouterConfig::default();
2173        let router = Router::new(config);
2174
2175        // Backend task routed to claude should warn
2176        let task = create_test_task("1", "Fix API", vec!["backend".to_string()]);
2177        let profile = AgentProfile {
2178            role: "general".to_string(),
2179            skills: vec!["api".to_string()],
2180            tools: vec![],
2181            constraints: vec![],
2182        };
2183        let warning = router.check_routing_sanity(&task, "claude", &profile);
2184        assert!(warning.is_some());
2185        assert!(warning.unwrap().contains("backend"));
2186
2187        // Docs task routed to codex should warn
2188        let task = create_test_task("1", "Update README", vec!["docs".to_string()]);
2189        let warning = router.check_routing_sanity(&task, "codex", &profile);
2190        assert!(warning.is_some());
2191        assert!(warning.unwrap().contains("docs"));
2192
2193        // Normal routing should not warn
2194        let task = create_test_task("1", "Fix bug", vec!["bug".to_string()]);
2195        let warning = router.check_routing_sanity(&task, "codex", &profile);
2196        assert!(warning.is_none());
2197    }
2198
2199    #[serial(cooldown_state)]
2200    #[tokio::test]
2201    async fn router_reload_preserves_structure() {
2202        crate::engine::cooldown::reset_global_state().await;
2203        let config = RouterConfig::default();
2204        let agents = vec!["claude".to_string()];
2205        let mut weights = AgentWeights::default();
2206        weights.ensure_agents(&agents);
2207        let mut router = Router {
2208            config,
2209            available_agents: agents,
2210            weights,
2211            llm_router: std::sync::Arc::new(LlmRouter::new()),
2212            ollama_router: None,
2213            rr_index: 0,
2214            last_agent: None,
2215            review_rr_index: 0,
2216            router_pool: vec![],
2217            pool_index: 0,
2218        };
2219
2220        // Reload — should re-read config and remain valid
2221        router.reload_async().await;
2222
2223        // After reload, mode should be a valid value (llm, local, or round_robin)
2224        assert!(
2225            router.config.mode == "llm"
2226                || router.config.mode == "round_robin"
2227                || router.config.mode == "local",
2228            "mode should be 'llm', 'local', or 'round_robin', got '{}'",
2229            router.config.mode
2230        );
2231        // Fallback executor should always be set
2232        assert!(!router.config.fallback_executor.is_empty());
2233        // Tools should always be populated
2234        assert!(!router.config.allowed_tools.is_empty());
2235    }
2236
2237    // --- Weighted round-robin tests ---
2238
2239    #[serial(cooldown_state)]
2240    #[test]
2241    fn rate_limit_state_defaults_to_full_weight() {
2242        let state = RateLimitState::default();
2243        assert_eq!(state.weight, DEFAULT_WEIGHT);
2244        assert_eq!(state.consecutive_hits, 0);
2245        assert!(state.last_limited_at.is_none());
2246        assert!(!state.is_limited());
2247    }
2248
2249    #[serial(cooldown_state)]
2250    #[test]
2251    fn rate_limit_state_decays_on_hit() {
2252        let mut state = RateLimitState::default();
2253        state.record_rate_limit();
2254
2255        assert!(state.weight < DEFAULT_WEIGHT);
2256        assert_eq!(state.consecutive_hits, 1);
2257        assert!(state.last_limited_at.is_some());
2258        assert!(state.is_limited());
2259
2260        // Weight should be DEFAULT_WEIGHT * RATE_LIMIT_DECAY
2261        let expected = DEFAULT_WEIGHT * RATE_LIMIT_DECAY;
2262        assert!((state.weight - expected).abs() < 1e-10);
2263    }
2264
2265    #[serial(cooldown_state)]
2266    #[test]
2267    fn rate_limit_state_never_drops_below_min() {
2268        let mut state = RateLimitState::default();
2269
2270        // Hit many times
2271        for _ in 0..100 {
2272            state.record_rate_limit();
2273        }
2274
2275        assert!(state.weight >= MIN_WEIGHT);
2276        assert_eq!(state.consecutive_hits, 100);
2277    }
2278
2279    #[serial(cooldown_state)]
2280    #[test]
2281    fn rate_limit_state_recovers_on_success() {
2282        let mut state = RateLimitState::default();
2283
2284        // Decay first
2285        state.record_rate_limit();
2286        state.record_rate_limit();
2287        let after_decay = state.weight;
2288
2289        // Record success
2290        state.record_success();
2291        assert!(state.weight > after_decay);
2292        assert_eq!(state.consecutive_hits, 0);
2293    }
2294
2295    #[serial(cooldown_state)]
2296    #[test]
2297    fn rate_limit_state_success_caps_at_default() {
2298        let mut state = RateLimitState::default();
2299
2300        // Already at full weight, success shouldn't exceed it
2301        state.record_success();
2302        assert_eq!(state.weight, DEFAULT_WEIGHT);
2303    }
2304
2305    #[serial(cooldown_state)]
2306    #[test]
2307    fn agent_weights_ensure_agents() {
2308        let mut weights = AgentWeights::default();
2309        let agents = vec!["claude".to_string(), "codex".to_string()];
2310        weights.ensure_agents(&agents);
2311
2312        assert_eq!(weights.states.len(), 2);
2313        assert_eq!(weights.get_weight("claude"), DEFAULT_WEIGHT);
2314        assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2315    }
2316
2317    #[serial(cooldown_state)]
2318    #[test]
2319    fn agent_weights_ensure_agents_preserves_existing() {
2320        let mut weights = AgentWeights::default();
2321        let agents = vec!["claude".to_string(), "codex".to_string()];
2322        weights.ensure_agents(&agents);
2323
2324        // Reduce claude's weight
2325        weights.record_rate_limit("claude");
2326        let claude_weight = weights.get_weight("claude");
2327        assert!(claude_weight < DEFAULT_WEIGHT);
2328
2329        // Ensure agents again — claude's weight should be preserved
2330        let agents2 = vec![
2331            "claude".to_string(),
2332            "codex".to_string(),
2333            "opencode".to_string(),
2334        ];
2335        weights.ensure_agents(&agents2);
2336
2337        assert_eq!(weights.get_weight("claude"), claude_weight);
2338        assert_eq!(weights.get_weight("opencode"), DEFAULT_WEIGHT);
2339    }
2340
2341    #[test]
2342    fn default_missing_weight_uses_minimum_of_configured() {
2343        use super::weights::default_missing_weight;
2344
2345        let mut configured = std::collections::HashMap::new();
2346        configured.insert("claude".to_string(), 0.2);
2347        configured.insert("codex".to_string(), 0.2);
2348        configured.insert("opencode".to_string(), 0.2);
2349        configured.insert("kimi".to_string(), 0.2);
2350
2351        // minimax omitted from router.weights — must not silently outrank
2352        // every explicitly configured agent (issue #3596).
2353        assert_eq!(default_missing_weight(&configured), 0.2);
2354    }
2355
2356    #[test]
2357    fn default_missing_weight_falls_back_when_none_configured() {
2358        use super::weights::default_missing_weight;
2359
2360        let configured = std::collections::HashMap::new();
2361        assert_eq!(default_missing_weight(&configured), DEFAULT_WEIGHT);
2362    }
2363
2364    #[serial(cooldown_state)]
2365    #[test]
2366    fn ensure_agents_with_weights_missing_agent_does_not_outrank_configured() {
2367        let mut weights = AgentWeights::default();
2368        let mut configured = std::collections::HashMap::new();
2369        configured.insert("claude".to_string(), 0.2);
2370        configured.insert("codex".to_string(), 0.2);
2371        configured.insert("opencode".to_string(), 0.2);
2372        configured.insert("kimi".to_string(), 0.2);
2373
2374        let agents = vec![
2375            "claude".to_string(),
2376            "codex".to_string(),
2377            "opencode".to_string(),
2378            "kimi".to_string(),
2379            "minimax".to_string(),
2380        ];
2381        weights.ensure_agents_with_weights(&agents, &configured);
2382
2383        // minimax was omitted from router.weights but must not get the old
2384        // DEFAULT_WEIGHT of 1.0, which would outrank every configured agent.
2385        assert_eq!(weights.get_weight("minimax"), 0.2);
2386        assert_eq!(weights.get_weight("claude"), 0.2);
2387    }
2388
2389    #[serial(cooldown_state)]
2390    #[test]
2391    fn agent_weights_record_rate_limit() {
2392        let mut weights = AgentWeights::default();
2393        weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2394
2395        weights.record_rate_limit("claude");
2396
2397        assert!(weights.get_weight("claude") < DEFAULT_WEIGHT);
2398        assert_eq!(weights.get_weight("codex"), DEFAULT_WEIGHT);
2399    }
2400
2401    #[serial(cooldown_state)]
2402    #[test]
2403    fn agent_weights_record_success() {
2404        let mut weights = AgentWeights::default();
2405        weights.ensure_agents(&["claude".to_string()]);
2406
2407        weights.record_rate_limit("claude");
2408        let after_limit = weights.get_weight("claude");
2409
2410        weights.record_success("claude");
2411        assert!(weights.get_weight("claude") > after_limit);
2412    }
2413
2414    #[serial(cooldown_state)]
2415    #[test]
2416    fn agent_weights_snapshot() {
2417        let mut weights = AgentWeights::default();
2418        weights.ensure_agents(&["claude".to_string(), "codex".to_string()]);
2419        weights.record_rate_limit("claude");
2420
2421        let snap = weights.snapshot();
2422        assert_eq!(snap.len(), 2);
2423
2424        // Snapshot is sorted alphabetically
2425        assert_eq!(snap[0].0, "claude");
2426        assert!(snap[0].1 < DEFAULT_WEIGHT);
2427        assert_eq!(snap[0].2, 1); // 1 hit
2428
2429        assert_eq!(snap[1].0, "codex");
2430        assert_eq!(snap[1].1, DEFAULT_WEIGHT);
2431        assert_eq!(snap[1].2, 0); // 0 hits
2432    }
2433
2434    #[serial(cooldown_state)]
2435    #[test]
2436    fn agent_weights_weighted_select_favors_higher_weight() {
2437        let mut weights = AgentWeights::default();
2438        let agents = vec!["claude".to_string(), "codex".to_string()];
2439        weights.ensure_agents(&agents);
2440
2441        // Heavily penalize claude
2442        for _ in 0..10 {
2443            weights.record_rate_limit("claude");
2444        }
2445
2446        // Run many selections and count
2447        let mut claude_count = 0;
2448        let mut codex_count = 0;
2449        for _ in 0..100 {
2450            match weights.weighted_select(&agents, "task-1") {
2451                Some(ref a) if a == "claude" => claude_count += 1,
2452                Some(ref a) if a == "codex" => codex_count += 1,
2453                _ => {}
2454            }
2455        }
2456
2457        // Codex should get significantly more selections
2458        // (claude weight is near MIN_WEIGHT, codex is at 1.0)
2459        assert!(
2460            codex_count > claude_count,
2461            "codex ({codex_count}) should get more selections than claude ({claude_count})"
2462        );
2463    }
2464
2465    #[serial(cooldown_state)]
2466    #[test]
2467    fn agent_weights_weighted_select_empty_returns_none() {
2468        let weights = AgentWeights::default();
2469        assert!(weights.weighted_select(&[], "task-1").is_none());
2470    }
2471
2472    #[serial(cooldown_state)]
2473    #[test]
2474    fn agent_weights_batch_routing_produces_varied_selections() {
2475        // Regression test: when routing a batch of tasks in the same tick,
2476        // different task IDs should not all select the same agent.
2477        let mut weights = AgentWeights::default();
2478        let agents = vec!["claude".to_string(), "codex".to_string()];
2479        weights.ensure_agents(&agents);
2480
2481        // Simulate batch routing: 20 different task IDs called in rapid succession
2482        let task_ids: Vec<String> = (1..=20).map(|i| format!("task-{i}")).collect();
2483        let selections: Vec<String> = task_ids
2484            .iter()
2485            .filter_map(|id| weights.weighted_select(&agents, id))
2486            .collect();
2487
2488        // With 20 tasks and 2 agents of equal weight, we should see both agents selected.
2489        // The probability of all 20 selecting the same agent by chance is (0.5)^19 < 1e-6.
2490        let claude_count = selections.iter().filter(|a| a.as_str() == "claude").count();
2491        let codex_count = selections.iter().filter(|a| a.as_str() == "codex").count();
2492        assert!(
2493            claude_count > 0 && codex_count > 0,
2494            "batch routing should distribute across agents; got claude={claude_count} codex={codex_count}"
2495        );
2496    }
2497
2498    #[serial(cooldown_state)]
2499    #[test]
2500    fn agent_weights_weighted_select_single_agent() {
2501        let mut weights = AgentWeights::default();
2502        let agents = vec!["claude".to_string()];
2503        weights.ensure_agents(&agents);
2504
2505        let selected = weights.weighted_select(&agents, "task-1");
2506        assert_eq!(selected, Some("claude".to_string()));
2507    }
2508
2509    #[serial(cooldown_state)]
2510    #[test]
2511    fn agent_weights_tick_recovery_restores_weight() {
2512        let mut weights = AgentWeights::default();
2513        weights.ensure_agents(&["claude".to_string()]);
2514
2515        // Set up a rate limit that looks like it happened long ago
2516        let state = weights.states.get_mut("claude").unwrap();
2517        state.weight = 0.5;
2518        state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2519        state.consecutive_hits = 2;
2520
2521        let before = weights.get_weight("claude");
2522        weights.tick_recovery();
2523        let after = weights.get_weight("claude");
2524
2525        assert!(after > before, "weight should increase after recovery tick");
2526    }
2527
2528    #[serial(cooldown_state)]
2529    #[test]
2530    fn agent_weights_tick_recovery_clears_on_full_restore() {
2531        let mut weights = AgentWeights::default();
2532        weights.ensure_agents(&["claude".to_string()]);
2533
2534        // Set weight just below full with an old limit
2535        let state = weights.states.get_mut("claude").unwrap();
2536        state.weight = DEFAULT_WEIGHT - 0.01;
2537        state.last_limited_at = Some(Instant::now() - RECOVERY_DELAY - Duration::from_secs(1));
2538        state.consecutive_hits = 1;
2539
2540        weights.tick_recovery();
2541
2542        let state = weights.states.get("claude").unwrap();
2543        assert_eq!(state.weight, DEFAULT_WEIGHT);
2544        assert!(state.last_limited_at.is_none());
2545        assert_eq!(state.consecutive_hits, 0);
2546    }
2547
2548    #[serial(cooldown_state)]
2549    #[tokio::test]
2550    async fn route_weighted_round_robin_basic() {
2551        crate::engine::cooldown::reset_global_state().await;
2552        let mut config = RouterConfig {
2553            weighted_round_robin: true,
2554            ..Default::default()
2555        };
2556        // Provide a concrete model for codex so label override can be used.
2557        config
2558            .model_map
2559            .entry("medium".to_string())
2560            .or_default()
2561            .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2562
2563        let agents = vec!["claude".to_string(), "codex".to_string()];
2564        let mut weights = AgentWeights::default();
2565        weights.ensure_agents(&agents);
2566        let mut router = Router {
2567            config,
2568            available_agents: agents,
2569            weights,
2570            llm_router: std::sync::Arc::new(LlmRouter::new()),
2571            ollama_router: None,
2572            rr_index: 0,
2573            last_agent: None,
2574            review_rr_index: 0,
2575            router_pool: vec![],
2576            pool_index: 0,
2577        };
2578
2579        let task = create_test_task("1", "Test task", vec![]);
2580        let store = test_store().await;
2581        let result = router.route(&task, &store, "test/repo").await.unwrap();
2582
2583        // Should use weighted_round_robin
2584        assert!(result.reason.contains("weighted_round_robin"));
2585        assert!(
2586            result.agent == "claude" || result.agent == "codex",
2587            "agent should be claude or codex, got '{}'",
2588            result.agent
2589        );
2590    }
2591
2592    #[serial(cooldown_state)]
2593    #[tokio::test]
2594    async fn route_weighted_round_robin_respects_label_override() {
2595        crate::engine::cooldown::reset_global_state().await;
2596        let mut config = RouterConfig {
2597            weighted_round_robin: true,
2598            ..Default::default()
2599        };
2600        // Provide a concrete model for codex so label override can be used.
2601        config
2602            .model_map
2603            .entry("medium".to_string())
2604            .or_default()
2605            .insert("codex".to_string(), vec!["gpt-5.2".to_string()]);
2606
2607        let agents = vec!["claude".to_string(), "codex".to_string()];
2608        let mut weights = AgentWeights::default();
2609        weights.ensure_agents(&agents);
2610        let mut router = Router {
2611            config,
2612            available_agents: agents,
2613            weights,
2614            llm_router: std::sync::Arc::new(LlmRouter::new()),
2615            ollama_router: None,
2616            rr_index: 0,
2617            last_agent: None,
2618            review_rr_index: 0,
2619            router_pool: vec![],
2620            pool_index: 0,
2621        };
2622
2623        // Label override should take precedence over weighted routing
2624        let task = create_test_task("1", "Test task", vec!["agent:codex".to_string()]);
2625        let store = test_store().await;
2626        let result = router.route(&task, &store, "test/repo").await.unwrap();
2627        assert_eq!(result.agent, "codex");
2628        assert!(result.reason.contains("label"));
2629    }
2630
2631    #[serial(cooldown_state)]
2632    #[test]
2633    fn router_config_weighted_round_robin_default_false() {
2634        let config = RouterConfig::default();
2635        assert!(!config.weighted_round_robin);
2636    }
2637
2638    #[serial(cooldown_state)]
2639    #[test]
2640    fn router_record_and_recover_weights() {
2641        let config = RouterConfig {
2642            weighted_round_robin: true,
2643            ..Default::default()
2644        };
2645        let mut router = Router::new(config);
2646        // Override discovered agents for test
2647        router.available_agents = vec!["claude".to_string(), "codex".to_string()];
2648        router.weights.ensure_agents(&router.available_agents);
2649
2650        // Record rate limit
2651        router.record_rate_limit("claude");
2652        assert!(router.weights.get_weight("claude") < DEFAULT_WEIGHT);
2653        assert_eq!(router.weights.get_weight("codex"), DEFAULT_WEIGHT);
2654
2655        // Record success for claude
2656        router.record_success("claude");
2657        let after_success = router.weights.get_weight("claude");
2658        assert!(after_success > MIN_WEIGHT);
2659    }
2660
2661    #[serial(cooldown_state)]
2662    #[test]
2663    fn review_rr_index_advances() {
2664        let mut config = RouterConfig::default();
2665        // Set up model_map so has_available_model_for_complexity returns true for test agents
2666        for agent in &["test_a", "test_b", "test_c"] {
2667            config
2668                .model_map
2669                .entry("medium".to_string())
2670                .or_default()
2671                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2672        }
2673        let agents = vec![
2674            "test_a".to_string(),
2675            "test_b".to_string(),
2676            "test_c".to_string(),
2677        ];
2678        let mut weights = AgentWeights::default();
2679        weights.ensure_agents(&agents);
2680        let mut router = Router {
2681            config,
2682            available_agents: agents,
2683            weights,
2684            llm_router: std::sync::Arc::new(LlmRouter::new()),
2685            ollama_router: None,
2686            rr_index: 0,
2687            last_agent: None,
2688            review_rr_index: 0,
2689            router_pool: vec![],
2690            pool_index: 0,
2691        };
2692
2693        let a1 = router.next_round_robin_agent(&[], "medium").unwrap();
2694        let a2 = router.next_round_robin_agent(&[], "medium").unwrap();
2695        let a3 = router.next_round_robin_agent(&[], "medium").unwrap();
2696
2697        // All three agents should appear (order depends on start index)
2698        let mut seen = vec![a1, a2, a3];
2699        seen.sort();
2700        assert_eq!(seen, vec!["test_a", "test_b", "test_c"]);
2701    }
2702
2703    #[serial(cooldown_state)]
2704    #[test]
2705    fn review_rr_fallback_with_cooled_agent() {
2706        // Regression test for #2428: when one agent is in cooldown (candidates.len() <
2707        // available_agents.len()), review_rr_index can exceed candidates.len()-1 causing
2708        // candidates.get(idx) to return None even though candidates is non-empty.
2709        let mut config = RouterConfig::default();
2710        // Register 3 agents in model_map but only 2 will be "available" (simulate cooldown)
2711        for agent in &["test_a", "test_b", "test_c"] {
2712            config
2713                .model_map
2714                .entry("medium".to_string())
2715                .or_default()
2716                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2717        }
2718        // Only 2 candidates available (test_c is in cooldown / not in available_agents)
2719        let agents = vec!["test_a".to_string(), "test_b".to_string()];
2720        let mut weights = AgentWeights::default();
2721        weights.ensure_agents(&agents);
2722        let mut router = Router {
2723            config,
2724            available_agents: agents,
2725            weights,
2726            llm_router: std::sync::Arc::new(LlmRouter::new()),
2727            ollama_router: None,
2728            rr_index: 0,
2729            last_agent: None,
2730            // Start index at 2, which is >= candidates.len() (2), triggering the bug
2731            review_rr_index: 2,
2732            router_pool: vec![],
2733            pool_index: 0,
2734        };
2735
2736        // All candidates excluded — should fall back to first candidate, not None
2737        let result = router.next_round_robin_agent(&["test_a", "test_b"], "medium");
2738        assert!(
2739            result.is_some(),
2740            "next_round_robin_agent must not return None when candidates is non-empty"
2741        );
2742    }
2743
2744    #[serial(cooldown_state)]
2745    #[test]
2746    fn review_rr_index_uses_candidates_len_not_available_len() {
2747        // Regression test for #2449: review_rr_index must advance modulo candidates.len(),
2748        // not available_agents.len(). When an agent has no model for the complexity,
2749        // candidates.len() < available_agents.len() and the old code produced a drifting
2750        // index that caused non-uniform distribution.
2751        let mut config = RouterConfig::default();
2752        // Only register models for test_a and test_b — test_c has no model, so it is
2753        // filtered out by has_available_model_for_complexity → candidates.len() == 2.
2754        for agent in &["test_a", "test_b"] {
2755            config
2756                .model_map
2757                .entry("medium".to_string())
2758                .or_default()
2759                .insert((*agent).to_string(), vec!["test-model".to_string()]);
2760        }
2761        let agents = vec![
2762            "test_a".to_string(),
2763            "test_b".to_string(),
2764            "test_c".to_string(), // available but no model → filtered out of candidates
2765        ];
2766        let mut weights = AgentWeights::default();
2767        weights.ensure_agents(&agents);
2768        let mut router = Router {
2769            config,
2770            available_agents: agents,
2771            weights,
2772            llm_router: std::sync::Arc::new(LlmRouter::new()),
2773            ollama_router: None,
2774            rr_index: 0,
2775            last_agent: None,
2776            review_rr_index: 0,
2777            router_pool: vec![],
2778            pool_index: 0,
2779        };
2780
2781        // With the bug: index advances modulo 3 (available_agents.len()), so the
2782        // sequence would be idx=0→1, idx=1→2, idx=2→0, meaning selection is
2783        // candidates[0%2]=test_a, candidates[1%2]=test_b, candidates[2%2]=test_a —
2784        // looks uniform by accident, but adding a 4th available agent breaks it.
2785        //
2786        // The fix: index advances modulo 2 (candidates.len()), giving a clean 0→1→0→1
2787        // cycle. Verify this by checking review_rr_index after each call.
2788
2789        let r1 = router.next_round_robin_agent(&[], "medium").unwrap();
2790        assert_eq!(
2791            router.review_rr_index, 1,
2792            "index should be 1 after first call"
2793        );
2794
2795        let r2 = router.next_round_robin_agent(&[], "medium").unwrap();
2796        assert_eq!(
2797            router.review_rr_index, 0,
2798            "index should wrap to 0 after second call"
2799        );
2800
2801        let r3 = router.next_round_robin_agent(&[], "medium").unwrap();
2802        assert_eq!(
2803            router.review_rr_index, 1,
2804            "index should be 1 after third call"
2805        );
2806
2807        // Agents must alternate uniformly
2808        assert_ne!(r1, r2, "consecutive calls should select different agents");
2809        assert_eq!(r1, r3, "third call should match first (clean cycle)");
2810    }
2811
2812    #[serial(cooldown_state)]
2813    #[tokio::test]
2814    async fn last_agent_tracks_routing() {
2815        crate::engine::cooldown::reset_global_state().await;
2816        let mut config = RouterConfig {
2817            mode: "round_robin".to_string(),
2818            ..Default::default()
2819        };
2820        config
2821            .model_map
2822            .entry("medium".to_string())
2823            .or_default()
2824            .insert("test_x".to_string(), vec!["test-model-x".to_string()]);
2825        config
2826            .model_map
2827            .entry("medium".to_string())
2828            .or_default()
2829            .insert("test_y".to_string(), vec!["test-model-y".to_string()]);
2830        let agents = vec!["test_x".to_string(), "test_y".to_string()];
2831        let mut weights = AgentWeights::default();
2832        weights.ensure_agents(&agents);
2833        let mut router = Router {
2834            config,
2835            available_agents: agents,
2836            weights,
2837            llm_router: std::sync::Arc::new(LlmRouter::new()),
2838            ollama_router: None,
2839            rr_index: 0,
2840            last_agent: None,
2841            review_rr_index: 0,
2842            router_pool: vec![],
2843            pool_index: 0,
2844        };
2845
2846        assert!(router.last_agent.is_none());
2847
2848        let task = create_test_task("1", "Test routing", vec![]);
2849        let store = test_store().await;
2850        let result = router.route(&task, &store, "test/repo").await.unwrap();
2851
2852        assert!(router.last_agent.is_some());
2853        assert_eq!(router.last_agent.as_deref(), Some(result.agent.as_str()));
2854    }
2855
2856    // ── pool config ───────────────────────────────────────────────────────────
2857
2858    #[serial(cooldown_state)]
2859    #[test]
2860    fn parse_pool_entry_splits_on_first_colon() {
2861        let (agent, model) = super::parse_pool_entry("claude:haiku");
2862        assert_eq!(agent, "claude");
2863        assert_eq!(model, "haiku");
2864    }
2865
2866    #[serial(cooldown_state)]
2867    #[test]
2868    fn parse_pool_entry_model_with_slash() {
2869        // Models like "github-copilot/gpt-5-mini" must not be split on the slash
2870        let (agent, model) = super::parse_pool_entry("opencode:github-copilot/gpt-5-mini");
2871        assert_eq!(agent, "opencode");
2872        assert_eq!(model, "github-copilot/gpt-5-mini");
2873    }
2874
2875    #[serial(cooldown_state)]
2876    #[test]
2877    fn parse_pool_entry_no_colon() {
2878        let (agent, model) = super::parse_pool_entry("claude");
2879        assert_eq!(agent, "claude");
2880        assert_eq!(model, "");
2881    }
2882
2883    #[serial(cooldown_state)]
2884    #[test]
2885    fn effective_pool_uses_configured_pool() {
2886        let config = RouterConfig {
2887            pool: vec!["kimi:k2p5".to_string(), "claude:haiku".to_string()],
2888            ..RouterConfig::default()
2889        };
2890        let pool = config.effective_pool();
2891        assert_eq!(pool, vec!["kimi:k2p5", "claude:haiku"]);
2892    }
2893
2894    #[serial(cooldown_state)]
2895    #[test]
2896    fn effective_pool_falls_back_to_router_agent_model() {
2897        let config = RouterConfig::default();
2898        // Default has no pool → derives from router_agent:router_model
2899        let pool = config.effective_pool();
2900        assert_eq!(pool.len(), 1);
2901        assert!(pool[0].starts_with(&config.router_agent));
2902        assert!(pool[0].contains(&config.router_model));
2903    }
2904
2905    #[serial(cooldown_state)]
2906    #[test]
2907    fn effective_fallback_uses_configured_fallback() {
2908        let config = RouterConfig {
2909            fallback: "claude:haiku".to_string(),
2910            ..RouterConfig::default()
2911        };
2912        assert_eq!(config.effective_fallback(), "claude:haiku");
2913    }
2914
2915    #[serial(cooldown_state)]
2916    #[test]
2917    fn effective_fallback_derives_from_router_agent_model() {
2918        let config = RouterConfig::default();
2919        let fallback = config.effective_fallback();
2920        assert!(fallback.starts_with(&config.router_agent));
2921        assert!(fallback.contains(&config.router_model));
2922    }
2923
2924    // ── pool expansion ────────────────────────────────────────────────────────
2925
2926    #[serial(cooldown_state)]
2927    #[test]
2928    fn expand_pool_basic_entries() {
2929        let config = RouterConfig {
2930            pool: vec!["claude:haiku".to_string(), "kimi:k2p5".to_string()],
2931            ..RouterConfig::default()
2932        };
2933        let expanded = Router::expand_pool(&config);
2934        assert_eq!(expanded.len(), 2);
2935        assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2936        assert_eq!(expanded[1], ("kimi".to_string(), "k2p5".to_string()));
2937    }
2938
2939    #[serial(cooldown_state)]
2940    #[test]
2941    fn expand_pool_uses_effective_pool_when_pool_is_empty() {
2942        let config = RouterConfig {
2943            pool: vec![],
2944            router_agent: "claude".to_string(),
2945            router_model: "haiku".to_string(),
2946            ..RouterConfig::default()
2947        };
2948        let expanded = Router::expand_pool(&config);
2949        assert_eq!(expanded.len(), 1);
2950        assert_eq!(expanded[0], ("claude".to_string(), "haiku".to_string()));
2951    }
2952
2953    #[serial(cooldown_state)]
2954    #[test]
2955    fn expand_pool_opencode_free_skipped_when_not_installed() {
2956        // `opencode` is unlikely to be in the test environment's PATH.
2957        // If it's absent the entry should be silently skipped and the pool
2958        // should fall back to the effective_fallback entry.
2959        if crate::cmd_cache::command_exists("opencode") {
2960            // If opencode IS installed we can't control what it returns, so skip
2961            return;
2962        }
2963        let config = RouterConfig {
2964            pool: vec!["opencode:free".to_string()],
2965            fallback: "claude:haiku".to_string(),
2966            ..RouterConfig::default()
2967        };
2968        let expanded = Router::expand_pool(&config);
2969        // Should fall back to the fallback entry since all pool entries were skipped
2970        assert_eq!(expanded.len(), 1);
2971        assert_eq!(expanded[0].0, "claude");
2972        assert_eq!(expanded[0].1, "haiku");
2973    }
2974
2975    #[serial(cooldown_state)]
2976    #[test]
2977    fn expand_pool_single_entry_from_default() {
2978        let config = RouterConfig::default();
2979        let expanded = Router::expand_pool(&config);
2980        assert!(
2981            !expanded.is_empty(),
2982            "pool must never be empty after expansion"
2983        );
2984    }
2985
2986    // ── pool round-robin index ────────────────────────────────────────────────
2987
2988    #[serial(cooldown_state)]
2989    #[test]
2990    fn pool_index_initializes_to_zero() {
2991        let config = RouterConfig::default();
2992        let router = Router::new(config);
2993        assert_eq!(router.pool_index, 0);
2994    }
2995
2996    #[serial(cooldown_state)]
2997    #[test]
2998    fn router_pool_non_empty_after_construction() {
2999        let config = RouterConfig::default();
3000        let router = Router::new(config);
3001        assert!(!router.router_pool.is_empty());
3002    }
3003
3004    #[serial(cooldown_state)]
3005    #[tokio::test]
3006    async fn reload_resets_pool_index() {
3007        crate::engine::cooldown::reset_global_state().await;
3008        let config = RouterConfig::default();
3009        let mut router = Router::new(config);
3010        router.pool_index = 5;
3011        router.reload_async().await;
3012        assert_eq!(router.pool_index, 0);
3013    }
3014
3015    #[serial(cooldown_state)]
3016    #[test]
3017    fn pool_index_advances_after_failed_attempt() {
3018        let mut router = Router::new(RouterConfig::default());
3019        router.router_pool = vec![
3020            ("claude".to_string(), "haiku".to_string()),
3021            ("codex".to_string(), "gpt-5.2".to_string()),
3022            (
3023                "opencode".to_string(),
3024                "github-copilot/gpt-5-mini".to_string(),
3025            ),
3026        ];
3027        router.pool_index = 0;
3028
3029        router.advance_pool_index_after_attempt(0, router.router_pool.len());
3030
3031        assert_eq!(router.pool_index, 1);
3032    }
3033
3034    #[serial(cooldown_state)]
3035    #[test]
3036    fn pool_index_wraps_after_last_attempt() {
3037        let mut router = Router::new(RouterConfig::default());
3038        router.router_pool = vec![
3039            ("claude".to_string(), "haiku".to_string()),
3040            ("codex".to_string(), "gpt-5.2".to_string()),
3041            (
3042                "opencode".to_string(),
3043                "github-copilot/gpt-5-mini".to_string(),
3044            ),
3045        ];
3046        router.pool_index = 2;
3047
3048        router.advance_pool_index_after_attempt(2, router.router_pool.len());
3049
3050        assert_eq!(router.pool_index, 0);
3051    }
3052
3053    #[serial(cooldown_state)]
3054    #[test]
3055    fn earliest_pool_cooldown_returns_none_when_no_cooldowns() {
3056        let router = Router::new(RouterConfig::default());
3057        assert!(router.earliest_pool_cooldown().is_none());
3058    }
3059
3060    #[serial(cooldown_state)]
3061    #[tokio::test]
3062    async fn earliest_pool_cooldown_returns_earliest_when_set() {
3063        crate::engine::cooldown::reset_global_state().await;
3064        // Use explicit pool with predictable model names to avoid opencode discovery.
3065        let config = RouterConfig {
3066            pool: vec![
3067                "claude:haiku-4-5-20251001".to_string(),
3068                "opencode:haiku-4-5-20251001".to_string(),
3069            ],
3070            ..Default::default()
3071        };
3072        let router = Router::new(config);
3073        set_model_cooldown("claude", "haiku-4-5-20251001", 300, "silence_detected").await; // 5 min
3074        set_model_cooldown("opencode", "haiku-4-5-20251001", 600, "silence_detected").await; // 10 min
3075
3076        let earliest = router.earliest_pool_cooldown();
3077        assert!(earliest.is_some());
3078        let now = chrono::Utc::now().timestamp();
3079        let remaining = earliest.unwrap() - now;
3080        // Should be close to 300 (5 min, the minimum) — allow 5s tolerance for test runtime
3081        assert!((295..=300).contains(&remaining));
3082    }
3083
3084    #[serial(cooldown_state)]
3085    #[tokio::test]
3086    async fn earliest_pool_cooldown_skips_non_pool_entries() {
3087        crate::engine::cooldown::reset_global_state().await;
3088        // Use explicit pool so we can predict what entries exist.
3089        let config = RouterConfig {
3090            pool: vec![
3091                "claude:pool-model-a".to_string(),
3092                "opencode:pool-model-b".to_string(),
3093            ],
3094            ..Default::default()
3095        };
3096        let router = Router::new(config);
3097        // Set cooldown on a non-pool model — should not affect earliest_pool_cooldown
3098        set_model_cooldown("claude", "opus", 999, "silence_detected").await;
3099        // Set cooldown on a pool entry (opencode:pool-model-b)
3100        set_model_cooldown("opencode", "pool-model-b", 300, "silence_detected").await;
3101
3102        let earliest = router.earliest_pool_cooldown();
3103        assert!(earliest.is_some());
3104        let now = chrono::Utc::now().timestamp();
3105        let remaining = earliest.unwrap() - now;
3106        // Should be ~300 (the pool-model-b cooldown), NOT 999 (claude:opus is not in pool)
3107        assert!((295..=300).contains(&remaining));
3108    }
3109
3110    // ---- Pre-emptive health check: degraded agent exclusion ----
3111
3112    #[serial(cooldown_state)]
3113    #[test]
3114    fn degraded_agent_excluded_from_routing() {
3115        let mut config = RouterConfig::default();
3116        config
3117            .model_map
3118            .entry("medium".to_string())
3119            .or_default()
3120            .insert(
3121                "test_degraded_routing".to_string(),
3122                vec!["model-a".to_string()],
3123            );
3124
3125        let mut router = Router::new(config);
3126        router.available_agents = vec!["test_degraded_routing".to_string()];
3127
3128        // Agent is routable before degradation
3129        assert!(router.agent_is_routable("test_degraded_routing", "medium"));
3130
3131        // Mark as degraded
3132        mark_agent_degraded("test_degraded_routing");
3133
3134        // Now excluded from routing
3135        assert!(!router.agent_is_routable("test_degraded_routing", "medium"));
3136
3137        // available_agents_for_complexity returns empty
3138        assert!(
3139            router.available_agents_for_complexity("medium").is_empty(),
3140            "degraded agent should be excluded from available agents"
3141        );
3142
3143        // healthy_agent_count reflects exclusion
3144        assert_eq!(router.healthy_agent_count("medium"), 0);
3145
3146        // Cleanup
3147        clear_agent_degraded("test_degraded_routing");
3148    }
3149
3150    #[serial(cooldown_state)]
3151    #[test]
3152    fn healthy_agents_skips_degraded_but_keeps_healthy() {
3153        let mut config = RouterConfig::default();
3154        for agent in &["agent_healthy", "agent_degraded"] {
3155            config
3156                .model_map
3157                .entry("medium".to_string())
3158                .or_default()
3159                .insert(agent.to_string(), vec!["model-x".to_string()]);
3160        }
3161
3162        let mut router = Router::new(config);
3163        router.available_agents = vec!["agent_healthy".to_string(), "agent_degraded".to_string()];
3164
3165        mark_agent_degraded("agent_degraded");
3166
3167        let candidates = router.available_agents_for_complexity("medium");
3168        assert_eq!(candidates, vec!["agent_healthy".to_string()]);
3169        assert_eq!(router.healthy_agent_count("medium"), 1);
3170
3171        // Cleanup
3172        clear_agent_degraded("agent_degraded");
3173    }
3174
3175    #[serial(cooldown_state)]
3176    #[tokio::test]
3177    async fn refresh_health_marks_degraded_from_rate_limits() {
3178        crate::engine::cooldown::reset_global_state().await;
3179        let store = test_store().await;
3180        let agent = "test_refresh_health_agent";
3181
3182        let mut config = RouterConfig::default();
3183        config
3184            .model_map
3185            .entry("medium".to_string())
3186            .or_default()
3187            .insert(agent.to_string(), vec!["model-z".to_string()]);
3188
3189        let mut router = Router::new(config);
3190        router.available_agents = vec![agent.to_string()];
3191
3192        // Insert rate limit events exceeding default threshold (3)
3193        for _ in 0..4 {
3194            store
3195                .record_rate_limit(agent, "rate_limit", None)
3196                .await
3197                .unwrap();
3198        }
3199
3200        router.refresh_health(&store).await;
3201
3202        assert!(
3203            is_agent_degraded(agent),
3204            "agent should be degraded after exceeding rate limit threshold"
3205        );
3206        assert!(
3207            !router.agent_is_routable(agent, "medium"),
3208            "degraded agent should not be routable"
3209        );
3210
3211        // Cleanup
3212        clear_agent_degraded(agent);
3213    }
3214
3215    // ---- route_with_llm candidate filter: agents with all models cooled (#3509) ----
3216
3217    #[serial(cooldown_state)]
3218    #[tokio::test]
3219    async fn agent_has_any_available_model_false_when_only_model_cooled() {
3220        crate::engine::cooldown::reset_global_state().await;
3221        let mut config = RouterConfig::default();
3222        // Agent's only model, shared across every complexity tier it resolves to.
3223        for tier in ["simple", "medium", "complex", "review"] {
3224            config
3225                .model_map
3226                .entry(tier.to_string())
3227                .or_default()
3228                .insert(
3229                    "test_all_cooled".to_string(),
3230                    vec!["only-model".to_string()],
3231                );
3232        }
3233
3234        let mut router = Router::new(config);
3235        router.available_agents = vec!["test_all_cooled".to_string()];
3236
3237        assert!(router.agent_has_any_available_model("test_all_cooled"));
3238
3239        record_model_failure("test_all_cooled", "only-model").await;
3240        assert!(is_model_in_cooldown("test_all_cooled", "only-model"));
3241
3242        assert!(
3243            !router.agent_has_any_available_model("test_all_cooled"),
3244            "agent with its only model cooled across every tier should have no available model"
3245        );
3246    }
3247
3248    #[serial(cooldown_state)]
3249    #[tokio::test]
3250    async fn llm_candidate_excludes_agent_with_all_models_cooled() {
3251        // Regression test for #3509: route_with_llm's candidate filter must exclude an
3252        // agent that has no agent-level cooldown but whose only resolvable model (across
3253        // every complexity tier) is cooled, so it is never handed to the LLM classifier.
3254        crate::engine::cooldown::reset_global_state().await;
3255        let mut config = RouterConfig::default();
3256        for tier in ["simple", "medium", "complex", "review"] {
3257            config
3258                .model_map
3259                .entry(tier.to_string())
3260                .or_default()
3261                .insert("agent_cooled_model".to_string(), vec!["opus".to_string()]);
3262            config
3263                .model_map
3264                .get_mut(tier)
3265                .unwrap()
3266                .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3267        }
3268
3269        let mut router = Router::new(config);
3270        router.available_agents = vec![
3271            "agent_cooled_model".to_string(),
3272            "agent_healthy".to_string(),
3273        ];
3274
3275        record_model_failure("agent_cooled_model", "opus").await;
3276        assert!(is_model_in_cooldown("agent_cooled_model", "opus"));
3277        // No agent-level cooldown — the old agent-only filter would still offer it.
3278        assert!(!is_agent_in_cooldown("agent_cooled_model"));
3279
3280        let uncooled_agents: Vec<String> = router
3281            .available_agents
3282            .iter()
3283            .filter(|a| {
3284                !is_agent_in_cooldown(a)
3285                    && !is_agent_degraded(a)
3286                    && router.agent_has_available_model_for_coding_tiers(a)
3287            })
3288            .cloned()
3289            .collect();
3290
3291        assert_eq!(
3292            uncooled_agents,
3293            vec!["agent_healthy".to_string()],
3294            "agent whose only model is cooled must be excluded from the LLM candidate list"
3295        );
3296    }
3297
3298    #[serial(cooldown_state)]
3299    #[tokio::test]
3300    async fn llm_candidate_excludes_agent_with_only_simple_tier_uncooled() {
3301        // Regression test for #3521: an agent whose only healthy tier is `simple`
3302        // (its `medium`/`complex` tiers resolve to the same cooled model) must be
3303        // excluded from the LLM classification candidate list, otherwise the
3304        // classifier can pick it for a `medium`/`complex` task it can't serve,
3305        // wasting the classification call. Mirrors the real-world shape where
3306        // `simple` → sonnet (uncooled) and `medium`/`complex` → opus (cooled).
3307        crate::engine::cooldown::reset_global_state().await;
3308        let mut config = RouterConfig::default();
3309        config
3310            .model_map
3311            .entry("simple".to_string())
3312            .or_default()
3313            .insert("minimax_like".to_string(), vec!["sonnet".to_string()]);
3314        config
3315            .model_map
3316            .entry("medium".to_string())
3317            .or_default()
3318            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3319        config
3320            .model_map
3321            .entry("complex".to_string())
3322            .or_default()
3323            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3324        // review must be irrelevant to the coding-tier filter
3325        config
3326            .model_map
3327            .entry("review".to_string())
3328            .or_default()
3329            .insert("minimax_like".to_string(), vec!["opus".to_string()]);
3330        for tier in ["simple", "medium", "complex", "review"] {
3331            config
3332                .model_map
3333                .get_mut(tier)
3334                .unwrap()
3335                .insert("agent_healthy".to_string(), vec!["model-x".to_string()]);
3336        }
3337
3338        let mut router = Router::new(config);
3339        router.available_agents = vec!["minimax_like".to_string(), "agent_healthy".to_string()];
3340
3341        record_model_failure("minimax_like", "opus").await;
3342        assert!(is_model_in_cooldown("minimax_like", "opus"));
3343        // No agent-level cooldown, and the simple tier is healthy, so the loose
3344        // "any tier" check still passes — this is exactly the leak #3511 missed.
3345        assert!(!is_agent_in_cooldown("minimax_like"));
3346        assert!(router.agent_has_any_available_model("minimax_like"));
3347        assert!(!router.agent_has_available_model_for_coding_tiers("minimax_like"));
3348
3349        let uncooled_agents: Vec<String> = router
3350            .available_agents
3351            .iter()
3352            .filter(|a| {
3353                !is_agent_in_cooldown(a)
3354                    && !is_agent_degraded(a)
3355                    && router.agent_has_available_model_for_coding_tiers(a)
3356            })
3357            .cloned()
3358            .collect();
3359
3360        assert_eq!(
3361            uncooled_agents,
3362            vec!["agent_healthy".to_string()],
3363            "agent with only its simple tier uncooled must be excluded from the LLM candidate list"
3364        );
3365    }
3366}