Skip to main content

oxicode_sdk/lifecycle/
supervisor.rs

1//! Agent supervisor — manages a pool of agents with spawn / resume / policy.
2//!
3//! Also contains `AgentHandle`: the per-agent lifecycle handle wrapping
4//! `Arc<Agent>` with atomic status transitions.
5
6use crate::error::{SdkError, SdkResult};
7use crate::lifecycle::snapshot::SnapshotStore;
8use crate::lifecycle::{AgentLifecycleEvent, AgentSnapshot, AgentStatus, MetricsSnapshot};
9use crate::routing::RoutingControl;
10use oxicode_agent::{AgentConfig, AgentTool, ProviderResolver, ToolRegistry};
11use parking_lot::RwLock;
12use std::collections::HashMap;
13use std::sync::Arc;
14use std::sync::atomic::{AtomicU8, Ordering};
15use tokio::sync::broadcast;
16
17// ── Internal status encoding (fits in AtomicU8) ──────────────────────────
18
19const STATUS_CREATED: u8 = 0;
20const STATUS_RUNNING: u8 = 1;
21const STATUS_SUSPENDED: u8 = 2;
22const STATUS_TERMINATED: u8 = 3;
23const STATUS_FAILED: u8 = 4;
24
25fn u8_to_status(v: u8) -> AgentStatus {
26    match v {
27        STATUS_CREATED => AgentStatus::Created,
28        STATUS_RUNNING => AgentStatus::Running,
29        STATUS_SUSPENDED => AgentStatus::Suspended,
30        STATUS_TERMINATED => AgentStatus::Terminated,
31        _ => AgentStatus::Failed,
32    }
33}
34
35// ── SupervisorPolicy ─────────────────────────────────────────────────────
36
37/// Supervisor restart policy.
38#[derive(Debug, Clone)]
39pub struct SupervisorPolicy {
40    /// Max restart attempts within the window.
41    pub max_restarts: usize,
42    /// Time window for counting restarts (seconds).
43    pub restart_window_secs: u64,
44    /// Backoff strategy.
45    pub backoff: RestartBackoff,
46}
47
48impl Default for SupervisorPolicy {
49    fn default() -> Self {
50        Self {
51            max_restarts: 3,
52            restart_window_secs: 60,
53            backoff: RestartBackoff::Exponential {
54                base_ms: 1000,
55                max_ms: 30_000,
56            },
57        }
58    }
59}
60
61impl SupervisorPolicy {
62    /// No automatic restarts.
63    pub fn no_restart() -> Self {
64        Self {
65            max_restarts: 0,
66            restart_window_secs: 0,
67            backoff: RestartBackoff::None,
68        }
69    }
70}
71
72/// Restart backoff strategy.
73#[derive(Debug, Clone)]
74pub enum RestartBackoff {
75    /// No delay.
76    None,
77    /// Fixed delay.
78    Fixed {
79        /// Delay before restarting, in milliseconds.
80        delay_ms: u64,
81    },
82    /// Exponential with cap.
83    Exponential {
84        /// Initial delay before restarting, in milliseconds.
85        base_ms: u64,
86        /// Upper bound on the exponential delay, in milliseconds.
87        max_ms: u64,
88    },
89}
90
91// ── AgentHandle ──────────────────────────────────────────────────────────
92
93/// Agent execution handle returned by `AgentSupervisor::spawn()`.
94///
95/// Wraps `Arc<Agent>` with lifecycle state management.
96/// Thread-safe: status tracked via atomic, cancel via shared flag.
97#[derive(Clone)]
98pub struct AgentHandle {
99    agent_id: String,
100    status: Arc<AtomicU8>,
101    agent: Arc<oxicode_agent::Agent>,
102    config: Arc<RwLock<AgentConfig>>,
103    metrics: Arc<crate::metrics::AgentMetrics>,
104    lifecycle_tx: broadcast::Sender<AgentLifecycleEvent>,
105    created_at_ms: u64,
106    parent_id: Option<String>,
107    routing: RoutingControl,
108}
109
110impl AgentHandle {
111    /// Create a new handle wrapping an agent.
112    pub(crate) fn new(
113        agent: oxicode_agent::Agent,
114        config: AgentConfig,
115        parent_id: Option<String>,
116        lifecycle_tx: broadcast::Sender<AgentLifecycleEvent>,
117    ) -> Self {
118        let routing = RoutingControl::new(crate::routing::RoutingConfig::default());
119        Self {
120            agent_id: if config.name.is_empty() {
121                uuid::Uuid::new_v4().to_string()
122            } else {
123                config.name.clone()
124            },
125            status: Arc::new(AtomicU8::new(STATUS_CREATED)),
126            agent: Arc::new(agent),
127            config: Arc::new(RwLock::new(config)),
128            metrics: Arc::new(crate::metrics::AgentMetrics::new()),
129            lifecycle_tx,
130            created_at_ms: AgentLifecycleEvent::now_ms(),
131            parent_id,
132            routing,
133        }
134    }
135
136    // ── Accessors ──────────────────────────────────────────
137
138    /// Agent identifier.
139    pub fn agent_id(&self) -> &str {
140        &self.agent_id
141    }
142
143    /// Parent agent ID (for delegation lineage).
144    pub fn parent_id(&self) -> Option<&str> {
145        self.parent_id.as_deref()
146    }
147
148    /// Creation timestamp (ms since epoch).
149    pub fn created_at_ms(&self) -> u64 {
150        self.created_at_ms
151    }
152
153    /// Current metrics snapshot.
154    pub fn metrics(&self) -> MetricsSnapshot {
155        self.metrics.snapshot()
156    }
157
158    /// Whether the agent is currently running.
159    pub fn is_running(&self) -> bool {
160        self.status() == AgentStatus::Running
161    }
162
163    /// Current lifecycle status.
164    pub fn status(&self) -> AgentStatus {
165        u8_to_status(self.status.load(Ordering::SeqCst))
166    }
167
168    // ── Execution ─────────────────────────────────────────
169
170    /// Run the agent with a prompt.
171    ///
172    /// Transitions `Created`/`Suspended` → `Running` → `Created` (on success)
173    /// or `Failed` (on error).
174    pub async fn run(
175        &self,
176        prompt: String,
177    ) -> SdkResult<(
178        oxicode_agent::types::Response,
179        Vec<oxicode_agent::AgentEvent>,
180    )> {
181        // CAS: Created → Running or Suspended → Running
182        let prev = self
183            .status
184            .compare_exchange(
185                STATUS_CREATED,
186                STATUS_RUNNING,
187                Ordering::SeqCst,
188                Ordering::SeqCst,
189            )
190            .or_else(|_| {
191                self.status.compare_exchange(
192                    STATUS_SUSPENDED,
193                    STATUS_RUNNING,
194                    Ordering::SeqCst,
195                    Ordering::SeqCst,
196                )
197            });
198
199        if prev.is_err() {
200            return Err(SdkError::AgentNotRunnable {
201                agent_id: self.agent_id.clone(),
202                status: self.status().to_string(),
203            });
204        }
205
206        self.emit(AgentLifecycleEvent::RunStart {
207            agent_id: self.agent_id.clone(),
208            timestamp_ms: AgentLifecycleEvent::now_ms(),
209        });
210
211        let start = std::time::Instant::now();
212        let result = self.agent.run(prompt).await;
213        let elapsed = start.elapsed();
214
215        match result {
216            Ok((response, events)) => {
217                let agent_state = self.agent.state();
218                let input_tokens = agent_state.input_tokens as u64;
219                let output_tokens = agent_state.output_tokens as u64;
220                let tool_count = events
221                    .iter()
222                    .filter(|e| matches!(e, oxicode_agent::AgentEvent::ToolExecutionStart { .. }))
223                    .count() as u64;
224                self.metrics.record_success(
225                    elapsed.as_millis() as u64,
226                    input_tokens,
227                    output_tokens,
228                    tool_count,
229                );
230                self.transition(STATUS_CREATED);
231                self.emit(AgentLifecycleEvent::RunEnd {
232                    agent_id: self.agent_id.clone(),
233                    timestamp_ms: AgentLifecycleEvent::now_ms(),
234                    success: true,
235                });
236                Ok((response, events))
237            }
238            Err(e) => {
239                self.transition(STATUS_FAILED);
240                self.emit(AgentLifecycleEvent::RunEnd {
241                    agent_id: self.agent_id.clone(),
242                    timestamp_ms: AgentLifecycleEvent::now_ms(),
243                    success: false,
244                });
245                Err(SdkError::ExecutionFailed {
246                    reason: e.to_string(),
247                })
248            }
249        }
250    }
251
252    /// Continue the conversation with a follow-up prompt.
253    ///
254    /// Equivalent to `run()` — the underlying agent maintains conversation history.
255    pub async fn continue_with(
256        &self,
257        prompt: String,
258    ) -> SdkResult<(
259        oxicode_agent::types::Response,
260        Vec<oxicode_agent::AgentEvent>,
261    )> {
262        self.run(prompt).await
263    }
264
265    /// Request cancellation of the current run.
266    pub fn cancel(&self) {
267        self.agent.cancel();
268    }
269
270    // ── Lifecycle ─────────────────────────────────────────
271
272    /// Suspend the agent and create a checkpoint snapshot.
273    ///
274    /// Transitions `Created`/`Running` → `Suspended`.
275    pub async fn suspend(&self) -> SdkResult<AgentSnapshot> {
276        let cur = self.status();
277        if !cur.is_runnable() && cur != AgentStatus::Running {
278            return Err(SdkError::AgentNotRunnable {
279                agent_id: self.agent_id.clone(),
280                status: cur.to_string(),
281            });
282        }
283
284        // Cancel running work first
285        if cur == AgentStatus::Running {
286            self.cancel();
287            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
288        }
289
290        let snapshot = AgentSnapshot::from_agent(
291            self.agent_id.clone(),
292            &self.config.read(),
293            &self.agent.state(),
294            &self.agent.tools(),
295            self.parent_id.clone(),
296            HashMap::new(),
297        );
298
299        self.transition(STATUS_SUSPENDED);
300        self.emit(AgentLifecycleEvent::Suspended {
301            agent_id: self.agent_id.clone(),
302            snapshot: Box::new(snapshot.clone()),
303            timestamp_ms: AgentLifecycleEvent::now_ms(),
304        });
305
306        Ok(snapshot)
307    }
308
309    /// Terminate the agent permanently (terminal state).
310    pub fn terminate(&self) -> SdkResult<()> {
311        if self.status().is_terminal() {
312            return Err(SdkError::AgentNotRunnable {
313                agent_id: self.agent_id.clone(),
314                status: self.status().to_string(),
315            });
316        }
317        self.transition(STATUS_TERMINATED);
318        self.emit(AgentLifecycleEvent::Terminated {
319            agent_id: self.agent_id.clone(),
320            timestamp_ms: AgentLifecycleEvent::now_ms(),
321        });
322        Ok(())
323    }
324
325    /// Take a snapshot without changing state.
326    pub fn snapshot(&self) -> SdkResult<AgentSnapshot> {
327        Ok(AgentSnapshot::from_agent(
328            self.agent_id.clone(),
329            &self.config.read(),
330            &self.agent.state(),
331            &self.agent.tools(),
332            self.parent_id.clone(),
333            HashMap::new(),
334        ))
335    }
336
337    // ── Dynamic configuration ──────────────────────────────
338
339    /// Switch model mid-conversation.
340    ///
341    /// The new provider is re-credentialed via the resolver; the old
342    /// `api_key` parameter was removed in 0.55.0 (issues #39/#40).
343    pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
344        let old = self.config.read().model_id.clone();
345        self.agent.switch_model(model_id)?;
346        self.config.write().model_id = model_id.to_string();
347        self.emit(AgentLifecycleEvent::ModelSwitched {
348            agent_id: self.agent_id.clone(),
349            from_model: old,
350            to_model: model_id.to_string(),
351            timestamp_ms: AgentLifecycleEvent::now_ms(),
352        });
353        Ok(())
354    }
355
356    /// Update system prompt for future runs.
357    pub fn set_system_prompt(&self, prompt: String) {
358        self.config.write().system_prompt = Some(prompt.clone());
359        self.agent.set_system_prompt(prompt);
360    }
361
362    /// Register a tool at runtime.
363    pub fn add_tool(&self, tool: impl AgentTool + 'static) {
364        self.agent.add_tool(tool);
365    }
366
367    // ── Runtime routing ─────────────────────────────────────
368
369    /// Get the runtime routing control for this agent.
370    ///
371    /// Allows dynamic enabling/disabling of routing, model exclusion,
372    /// and fallback model management.
373    pub fn routing(&self) -> &RoutingControl {
374        &self.routing
375    }
376
377    /// Convenience: disable routing.
378    pub fn disable_routing(&self) {
379        self.routing.set_enabled(false);
380    }
381
382    /// Convenience: enable routing.
383    pub fn enable_routing(&self) {
384        self.routing.set_enabled(true);
385    }
386
387    /// Convenience: exclude a model from routing.
388    pub fn exclude_route_model(&self, model_id: &str) {
389        self.routing.exclude_model(model_id);
390    }
391
392    // ── Internal ──────────────────────────────────────────
393
394    fn transition(&self, new_status: u8) {
395        self.status.store(new_status, Ordering::SeqCst);
396    }
397
398    fn emit(&self, event: AgentLifecycleEvent) {
399        let _ = self.lifecycle_tx.send(event);
400    }
401}
402
403impl std::fmt::Debug for AgentHandle {
404    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
405        f.debug_struct("AgentHandle")
406            .field("agent_id", &self.agent_id)
407            .field("status", &self.status())
408            .finish()
409    }
410}
411
412// ── AgentSupervisor ──────────────────────────────────────────────────────
413
414/// Manages a pool of agents with lifecycle operations.
415///
416/// Responsibilities:
417/// - Spawn / terminate agents
418/// - Persist snapshots via `SnapshotStore`
419/// - Broadcast lifecycle events
420/// - Supervise: auto-restart on failure (configurable)
421#[derive(Clone)]
422pub struct AgentSupervisor {
423    agents: Arc<RwLock<HashMap<String, AgentHandle>>>,
424    lifecycle_tx: broadcast::Sender<AgentLifecycleEvent>,
425    snapshot_store: Arc<dyn SnapshotStore>,
426    policy: SupervisorPolicy,
427    /// Tracks restart timestamps per agent for window enforcement.
428    restart_log: Arc<RwLock<HashMap<String, Vec<u64>>>>,
429    resolver: Arc<dyn ProviderResolver>,
430    /// Strong reference to the Oxicode engine, used to route spawns
431    /// through [`crate::AgentBuilder`] when an agent decorator is
432    /// configured. `None` when the supervisor was constructed
433    /// without one (legacy / direct callers).
434    oxicode: Option<Arc<crate::Oxicode>>,
435    /// Cross-cutting decorator applied to every spawned agent when
436    /// `oxicode` is `Some`. Ignored on the fast path (no decorator).
437    agent_decorator: Option<Arc<dyn crate::observability::AgentDecorator>>,
438}
439
440impl AgentSupervisor {
441    /// Create a new supervisor.
442    pub fn new(
443        resolver: Arc<dyn ProviderResolver>,
444        snapshot_store: Arc<dyn SnapshotStore>,
445    ) -> Self {
446        Self::with_policy(resolver, snapshot_store, SupervisorPolicy::default())
447    }
448    /// Create with a specific restart policy.
449    pub fn with_policy(
450        resolver: Arc<dyn ProviderResolver>,
451        snapshot_store: Arc<dyn SnapshotStore>,
452        policy: SupervisorPolicy,
453    ) -> Self {
454        let (tx, _) = broadcast::channel(1024);
455        Self {
456            agents: Arc::new(RwLock::new(HashMap::new())),
457            lifecycle_tx: tx,
458            snapshot_store,
459            policy,
460            restart_log: Arc::new(RwLock::new(HashMap::new())),
461            resolver,
462            oxicode: None,
463            agent_decorator: None,
464        }
465    }
466
467    /// Attach an [`crate::Oxicode`] reference and an [`crate::observability::AgentDecorator`]
468    /// to this supervisor. Subsequent [`Self::spawn`] calls will
469    /// route through `Oxicode::agent(config)` + `decorator.decorate()`
470    /// instead of the bare `Agent::new()` fast path, so the
471    /// decorator's audit / authorizer / tracer / cost hooks
472    /// actually run on every spawned agent.
473    ///
474    /// Both must be supplied together — the decorator is only
475    /// effective when the supervisor has an `Oxicode` to bind the
476    /// builder against.
477    pub fn with_agent_decorator(
478        mut self,
479        oxicode: Arc<crate::Oxicode>,
480        decorator: Arc<dyn crate::observability::AgentDecorator>,
481    ) -> Self {
482        self.oxicode = Some(oxicode);
483        self.agent_decorator = Some(decorator);
484        self
485    }
486
487    /// Subscribe to lifecycle events from all agents.
488    pub fn subscribe(&self) -> broadcast::Receiver<AgentLifecycleEvent> {
489        self.lifecycle_tx.subscribe()
490    }
491
492    // ── Agent management ──────────────────────────────────
493
494    /// Spawn a new agent.
495    ///
496    /// When the supervisor was configured with both an `Oxicode` reference
497    /// and an [`crate::observability::AgentDecorator`] (via
498    /// [`with_agent_decorator`](Self::with_agent_decorator) or
499    /// [`crate::builder::SupervisorBuilder::with_agent_decorator`]), this routes
500    /// through `Oxicode::agent(config)` and lets the decorator apply
501    /// audit / authorizer / tracer / cost hooks before `.build()`.
502    /// Otherwise it takes the legacy fast path
503    /// (`Agent::new(provider, config, tools)`), which leaves
504    /// observability unset.
505    pub fn spawn(&self, config: AgentConfig) -> anyhow::Result<AgentHandle> {
506        let agent = if let (Some(oxicode), Some(decorator)) =
507            (self.oxicode.as_ref(), self.agent_decorator.as_ref())
508        {
509            let builder = oxicode.agent(config.clone());
510            let builder = decorator.decorate(builder);
511            builder.build()?
512        } else {
513            let model = self
514                .resolver
515                .resolve_model(&config.model_id)
516                .ok_or_else(|| SdkError::ModelNotFound {
517                    model_id: config.model_id.clone(),
518                })?;
519            let provider = self
520                .resolver
521                .resolve_provider(&model.provider)
522                .ok_or_else(|| SdkError::ProviderNotFound {
523                    provider: model.provider.clone(),
524                })?;
525            let tools = Arc::new(ToolRegistry::new());
526            oxicode_agent::Agent::new(provider, config.clone(), tools)
527        };
528
529        let handle = AgentHandle::new(agent, config.clone(), None, self.lifecycle_tx.clone());
530
531        self.agents
532            .write()
533            .insert(handle.agent_id().to_string(), handle.clone());
534
535        self.emit(AgentLifecycleEvent::Spawned {
536            agent_id: handle.agent_id().to_string(),
537            parent_id: None,
538            model_id: config.model_id.clone(),
539            timestamp_ms: AgentLifecycleEvent::now_ms(),
540        });
541
542        Ok(handle)
543    }
544
545    /// Spawn a child agent linked to a parent (delegation lineage).
546    pub fn spawn_child(&self, parent_id: &str, config: AgentConfig) -> anyhow::Result<AgentHandle> {
547        let model = self
548            .resolver
549            .resolve_model(&config.model_id)
550            .ok_or_else(|| SdkError::ModelNotFound {
551                model_id: config.model_id.clone(),
552            })?;
553        let provider = self
554            .resolver
555            .resolve_provider(&model.provider)
556            .ok_or_else(|| SdkError::ProviderNotFound {
557                provider: model.provider.clone(),
558            })?;
559
560        let tools = Arc::new(ToolRegistry::new());
561        let agent = oxicode_agent::Agent::new(provider, config.clone(), tools);
562
563        let handle = AgentHandle::new(
564            agent,
565            config.clone(),
566            Some(parent_id.to_string()),
567            self.lifecycle_tx.clone(),
568        );
569
570        self.agents
571            .write()
572            .insert(handle.agent_id().to_string(), handle.clone());
573
574        self.emit(AgentLifecycleEvent::Spawned {
575            agent_id: handle.agent_id().to_string(),
576            parent_id: Some(parent_id.to_string()),
577            model_id: config.model_id.clone(),
578            timestamp_ms: AgentLifecycleEvent::now_ms(),
579        });
580
581        Ok(handle)
582    }
583
584    /// Get a handle by agent ID.
585    pub fn get(&self, agent_id: &str) -> Option<AgentHandle> {
586        self.agents.read().get(agent_id).cloned()
587    }
588
589    /// List all agents and their status.
590    pub fn list(&self) -> Vec<(String, AgentStatus)> {
591        self.agents
592            .read()
593            .iter()
594            .map(|(id, h)| (id.clone(), h.status()))
595            .collect()
596    }
597
598    /// Count agents by status.
599    pub fn count_by_status(&self) -> HashMap<AgentStatus, usize> {
600        let mut counts = HashMap::new();
601        for handle in self.agents.read().values() {
602            *counts.entry(handle.status()).or_insert(0) += 1;
603        }
604        counts
605    }
606
607    // ── Persistence ───────────────────────────────────────
608
609    /// Suspend and persist snapshot.
610    pub async fn suspend(&self, agent_id: &str) -> anyhow::Result<AgentSnapshot> {
611        let handle = self
612            .get(agent_id)
613            .ok_or_else(|| SdkError::SnapshotNotFound {
614                agent_id: agent_id.to_string(),
615            })?;
616        let snapshot = handle.suspend().await?;
617        self.snapshot_store.save(&snapshot).await?;
618        Ok(snapshot)
619    }
620
621    /// Restore agent from persisted snapshot.
622    pub async fn restore(&self, agent_id: &str) -> anyhow::Result<AgentHandle> {
623        // Check if already in pool
624        if let Some(handle) = self.get(agent_id) {
625            return Ok(handle);
626        }
627
628        let snapshot = self.snapshot_store.load(agent_id).await?.ok_or_else(|| {
629            SdkError::SnapshotNotFound {
630                agent_id: agent_id.to_string(),
631            }
632        })?;
633
634        self.restore_from_snapshot(snapshot).await
635    }
636
637    /// Restore from an in-memory snapshot.
638    pub async fn restore_from_snapshot(
639        &self,
640        snapshot: AgentSnapshot,
641    ) -> anyhow::Result<AgentHandle> {
642        let model = self
643            .resolver
644            .resolve_model(&snapshot.config.model_id)
645            .ok_or_else(|| SdkError::ModelNotFound {
646                model_id: snapshot.config.model_id.clone(),
647            })?;
648        let provider = self
649            .resolver
650            .resolve_provider(&model.provider)
651            .ok_or_else(|| SdkError::ProviderNotFound {
652                provider: model.provider.clone(),
653            })?;
654
655        let tools = Arc::new(ToolRegistry::new());
656        let agent = oxicode_agent::Agent::new(provider, snapshot.config.clone(), tools);
657
658        // Restore conversation state
659        let state_json = serde_json::to_value(&snapshot.state)?;
660        agent.import_state(state_json)?;
661
662        let handle = AgentHandle::new(
663            agent,
664            snapshot.config.clone(),
665            snapshot.parent_id.clone(),
666            self.lifecycle_tx.clone(),
667        );
668
669        self.agents
670            .write()
671            .insert(handle.agent_id().to_string(), handle.clone());
672
673        self.emit(AgentLifecycleEvent::Resumed {
674            agent_id: handle.agent_id().to_string(),
675            from_snapshot_id: Some(snapshot.agent_id.clone()),
676            timestamp_ms: AgentLifecycleEvent::now_ms(),
677        });
678
679        Ok(handle)
680    }
681
682    /// Terminate an agent and remove from the pool.
683    pub fn terminate(&self, agent_id: &str) -> anyhow::Result<()> {
684        let handle = self
685            .get(agent_id)
686            .ok_or_else(|| SdkError::SnapshotNotFound {
687                agent_id: agent_id.to_string(),
688            })?;
689
690        if handle.is_running() {
691            return Err(SdkError::AgentNotRunnable {
692                agent_id: agent_id.to_string(),
693                status: "running".to_string(),
694            }
695            .into());
696        }
697
698        handle.terminate()?;
699        self.agents.write().remove(agent_id);
700        self.restart_log.write().remove(agent_id);
701
702        Ok(())
703    }
704
705    // ── Auto-restart ───────────────────────────────────────
706
707    /// Check whether an agent can be auto-restarted based on the policy.
708    ///
709    /// Returns `true` if:
710    /// - `max_restarts > 0`
711    /// - The number of restarts within the window is less than `max_restarts`
712    pub fn can_restart(&self, agent_id: &str) -> bool {
713        if self.policy.max_restarts == 0 {
714            return false;
715        }
716        let now = AgentLifecycleEvent::now_ms();
717        let window_ms = self.policy.restart_window_secs * 1000;
718        let log = self.restart_log.read();
719        let restarts = log
720            .get(agent_id)
721            .map(|ts| {
722                ts.iter()
723                    .filter(|&&t| now.saturating_sub(t) <= window_ms)
724                    .count()
725            })
726            .unwrap_or(0);
727        restarts < self.policy.max_restarts
728    }
729
730    /// Restart a failed agent with the same config.
731    ///
732    /// Records the restart in the restart log and applies backoff delay.
733    /// Returns the new handle on success.
734    pub async fn restart(&self, agent_id: &str) -> SdkResult<AgentHandle> {
735        if !self.can_restart(agent_id) {
736            return Err(SdkError::InvalidState {
737                entity: "agent".into(),
738                reason: format!(
739                    "agent '{}' exceeded max restarts ({})",
740                    agent_id, self.policy.max_restarts
741                ),
742            });
743        }
744
745        // Get the old handle's config
746        let old = self.agents.read().get(agent_id).cloned();
747        let config = match &old {
748            Some(h) => h.config.read().clone(),
749            None => {
750                return Err(SdkError::SnapshotNotFound {
751                    agent_id: agent_id.to_string(),
752                });
753            }
754        };
755
756        // Apply backoff delay
757        if let Some(delay) = self.compute_backoff(agent_id) {
758            tokio::time::sleep(delay).await;
759        }
760
761        // Record the restart
762        self.restart_log
763            .write()
764            .entry(agent_id.to_string())
765            .or_default()
766            .push(AgentLifecycleEvent::now_ms());
767
768        // Remove old handle
769        self.agents.write().remove(agent_id);
770
771        // Spawn fresh agent with the same config
772        self.spawn(config).map_err(SdkError::from)
773    }
774
775    /// Compute backoff duration for a given agent based on restart history.
776    fn compute_backoff(&self, agent_id: &str) -> Option<std::time::Duration> {
777        let count = self
778            .restart_log
779            .read()
780            .get(agent_id)
781            .map(|ts| ts.len())
782            .unwrap_or(0);
783        if count == 0 {
784            return None;
785        }
786        match &self.policy.backoff {
787            RestartBackoff::None => None,
788            RestartBackoff::Fixed { delay_ms } => Some(std::time::Duration::from_millis(*delay_ms)),
789            RestartBackoff::Exponential { base_ms, max_ms } => {
790                let delay = (*base_ms).saturating_mul(2u64.saturating_pow(count as u32));
791                Some(std::time::Duration::from_millis(delay.min(*max_ms)))
792            }
793        }
794    }
795
796    // ── Internal ──────────────────────────────────────────
797
798    fn emit(&self, event: AgentLifecycleEvent) {
799        let _ = self.lifecycle_tx.send(event);
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use std::future::Future;
807    use std::pin::Pin;
808    use std::sync::Arc;
809
810    // ── Mocks ──────────────────────────────────────────────
811
812    fn mock_resolver() -> Arc<dyn ProviderResolver> {
813        struct MockProvider;
814        impl oxicode_ai::Provider for MockProvider {
815            fn stream<'a>(
816                &'a self,
817                _model: &'a oxicode_ai::Model,
818                _context: &'a oxicode_ai::Context,
819                _options: Option<oxicode_ai::StreamOptions>,
820            ) -> Pin<Box<dyn Future<Output = oxicode_ai::StreamResult> + Send + 'a>> {
821                Box::pin(
822                    async move { Err(oxicode_ai::ProviderError::NotImplemented("mock".into())) },
823                )
824            }
825        }
826
827        struct Mock;
828        impl ProviderResolver for Mock {
829            fn resolve_provider(&self, _name: &str) -> Option<Arc<dyn oxicode_ai::Provider>> {
830                Some(Arc::new(MockProvider))
831            }
832            fn resolve_model(&self, _model_id: &str) -> Option<oxicode_ai::Model> {
833                Some(oxicode_ai::Model::new(
834                    "anthropic/claude-sonnet-4-20250514",
835                    "Claude",
836                    oxicode_ai::Api::AnthropicMessages,
837                    "anthropic",
838                    "https://api.anthropic.com",
839                ))
840            }
841        }
842        Arc::new(Mock)
843    }
844
845    struct NoopStore;
846
847    impl SnapshotStore for NoopStore {
848        fn save<'a>(
849            &'a self,
850            _snapshot: &'a AgentSnapshot,
851        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
852            Box::pin(async { Ok(()) })
853        }
854        fn load<'a>(
855            &'a self,
856            _agent_id: &'a str,
857        ) -> Pin<Box<dyn Future<Output = anyhow::Result<Option<AgentSnapshot>>> + Send + 'a>>
858        {
859            Box::pin(async { Ok(None) })
860        }
861        fn list(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send + '_>> {
862            Box::pin(async { Ok(vec![]) })
863        }
864        fn delete<'a>(
865            &'a self,
866            _agent_id: &'a str,
867        ) -> Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'a>> {
868            Box::pin(async { Ok(()) })
869        }
870    }
871
872    fn make_supervisor() -> AgentSupervisor {
873        AgentSupervisor::new(
874            mock_resolver(),
875            Arc::new(NoopStore) as Arc<dyn SnapshotStore>,
876        )
877    }
878
879    fn test_config() -> AgentConfig {
880        AgentConfig {
881            model_id: "anthropic/claude-sonnet-4-20250514".into(),
882            name: uuid::Uuid::new_v4().to_string(),
883            ..Default::default()
884        }
885    }
886
887    // ── Tests ──────────────────────────────────────────────
888
889    #[test]
890    fn supervisor_policy_default() {
891        let policy = SupervisorPolicy::default();
892        assert_eq!(policy.max_restarts, 3);
893        assert!(matches!(policy.backoff, RestartBackoff::Exponential { .. }));
894    }
895
896    #[test]
897    fn supervisor_policy_no_restart() {
898        let policy = SupervisorPolicy::no_restart();
899        assert_eq!(policy.max_restarts, 0);
900        assert!(matches!(policy.backoff, RestartBackoff::None));
901    }
902
903    #[test]
904    fn supervisor_spawn_and_get() {
905        let supervisor = make_supervisor();
906        let handle = supervisor.spawn(test_config()).unwrap();
907        assert!(!handle.agent_id().is_empty());
908        assert_eq!(handle.status(), AgentStatus::Created);
909        assert_eq!(handle.parent_id(), None);
910    }
911
912    #[test]
913    fn supervisor_spawn_child() {
914        let supervisor = make_supervisor();
915        let parent = supervisor.spawn(test_config()).unwrap();
916        let child = supervisor
917            .spawn_child(parent.agent_id(), test_config())
918            .unwrap();
919        assert_eq!(child.parent_id(), Some(parent.agent_id()));
920    }
921
922    #[test]
923    fn supervisor_terminate() {
924        let supervisor = make_supervisor();
925        let handle = supervisor.spawn(test_config()).unwrap();
926        let id = handle.agent_id().to_string();
927        supervisor.terminate(&id).unwrap();
928        assert!(supervisor.get(&id).is_none());
929    }
930
931    #[test]
932    fn supervisor_list_and_count() {
933        let supervisor = make_supervisor();
934        supervisor.spawn(test_config()).unwrap();
935        supervisor.spawn(test_config()).unwrap();
936
937        let list = supervisor.list();
938        assert_eq!(list.len(), 2);
939
940        let counts = supervisor.count_by_status();
941        assert_eq!(counts.get(&AgentStatus::Created), Some(&2));
942    }
943
944    #[test]
945    fn handle_status_transitions() {
946        let supervisor = make_supervisor();
947        let handle = supervisor.spawn(test_config()).unwrap();
948
949        // Created → Terminated
950        handle.terminate().unwrap();
951        assert_eq!(handle.status(), AgentStatus::Terminated);
952        assert!(handle.status().is_terminal());
953
954        // Cannot terminate again
955        assert!(handle.terminate().is_err());
956    }
957
958    #[test]
959    fn handle_switch_model() {
960        let supervisor = make_supervisor();
961        let handle = supervisor.spawn(test_config()).unwrap();
962        // Will fail because provider doesn't actually exist, but tests the wiring
963        let result = handle.switch_model("openai/gpt-4o");
964        // Provider resolution happens at run time via agent, so this should propagate
965        // the agent's error. We just check the method exists and compiles.
966        let _ = result;
967    }
968
969    #[test]
970    fn handle_set_system_prompt() {
971        let supervisor = make_supervisor();
972        let handle = supervisor.spawn(test_config()).unwrap();
973        handle.set_system_prompt("You are a test agent.".into());
974        // Verify the config was updated
975        assert_eq!(
976            handle.config.read().system_prompt,
977            Some("You are a test agent.".into())
978        );
979    }
980
981    #[test]
982    fn handle_snapshot() {
983        let supervisor = make_supervisor();
984        let handle = supervisor.spawn(test_config()).unwrap();
985        let snap = handle.snapshot().unwrap();
986        assert_eq!(snap.agent_id, handle.agent_id());
987    }
988
989    #[test]
990    fn lifecycle_events_received() {
991        let supervisor = make_supervisor();
992        let mut rx = supervisor.subscribe();
993        supervisor.spawn(test_config()).unwrap();
994
995        let event = rx.try_recv().expect("should receive Spawned event");
996        match event {
997            AgentLifecycleEvent::Spawned { agent_id, .. } => {
998                assert!(!agent_id.is_empty());
999            }
1000            _ => panic!("Expected Spawned event"),
1001        }
1002    }
1003
1004    // ── New feature tests ──────────────────────────────────
1005
1006    #[test]
1007    fn handle_has_routing_control() {
1008        let supervisor = make_supervisor();
1009        let handle = supervisor.spawn(test_config()).unwrap();
1010        // Default: routing enabled
1011        assert!(handle.routing().is_enabled());
1012    }
1013
1014    #[test]
1015    fn handle_routing_toggle() {
1016        let supervisor = make_supervisor();
1017        let handle = supervisor.spawn(test_config()).unwrap();
1018        handle.disable_routing();
1019        assert!(!handle.routing().is_enabled());
1020        handle.enable_routing();
1021        assert!(handle.routing().is_enabled());
1022    }
1023
1024    #[test]
1025    fn handle_routing_exclude_model() {
1026        let supervisor = make_supervisor();
1027        let handle = supervisor.spawn(test_config()).unwrap();
1028        handle.exclude_route_model("openai/gpt-4o");
1029        assert!(
1030            handle
1031                .routing()
1032                .excluded_models()
1033                .contains(&"openai/gpt-4o".to_string())
1034        );
1035    }
1036
1037    #[test]
1038    fn handle_routing_fallback_models() {
1039        let supervisor = make_supervisor();
1040        let handle = supervisor.spawn(test_config()).unwrap();
1041        handle
1042            .routing()
1043            .set_fallback_models(vec!["anthropic/claude-sonnet-4-20250514".into()]);
1044        assert_eq!(handle.routing().fallback_models().len(), 1);
1045    }
1046
1047    #[test]
1048    fn supervisor_can_restart_default_policy() {
1049        let supervisor = make_supervisor();
1050        let handle = supervisor.spawn(test_config()).unwrap();
1051        let id = handle.agent_id().to_string();
1052        // Default policy: max_restarts=3, so can_restart should be true
1053        assert!(supervisor.can_restart(&id));
1054    }
1055
1056    #[test]
1057    fn supervisor_cannot_restart_no_restart_policy() {
1058        let supervisor = AgentSupervisor::with_policy(
1059            mock_resolver(),
1060            Arc::new(NoopStore) as Arc<dyn SnapshotStore>,
1061            SupervisorPolicy::no_restart(),
1062        );
1063        let handle = supervisor.spawn(test_config()).unwrap();
1064        let id = handle.agent_id().to_string();
1065        assert!(!supervisor.can_restart(&id));
1066    }
1067
1068    #[tokio::test]
1069    async fn supervisor_restart_with_no_restart_policy_fails() {
1070        let supervisor = AgentSupervisor::with_policy(
1071            mock_resolver(),
1072            Arc::new(NoopStore) as Arc<dyn SnapshotStore>,
1073            SupervisorPolicy::no_restart(),
1074        );
1075        let handle = supervisor.spawn(test_config()).unwrap();
1076        let id = handle.agent_id().to_string();
1077        let result = supervisor.restart(&id).await;
1078        assert!(result.is_err());
1079    }
1080
1081    #[tokio::test]
1082    async fn supervisor_restart_spawns_new_agent() {
1083        // Use Fixed backoff with 0 delay for fast test
1084        let policy = SupervisorPolicy {
1085            max_restarts: 3,
1086            restart_window_secs: 60,
1087            backoff: RestartBackoff::Fixed { delay_ms: 0 },
1088        };
1089        let supervisor = AgentSupervisor::with_policy(
1090            mock_resolver(),
1091            Arc::new(NoopStore) as Arc<dyn SnapshotStore>,
1092            policy,
1093        );
1094        let handle = supervisor.spawn(test_config()).unwrap();
1095        let old_id = handle.agent_id().to_string();
1096
1097        let new_handle = supervisor.restart(&old_id).await.unwrap();
1098        // Restart creates a new handle (same name = same ID, but fresh state)
1099        assert!(supervisor.get(new_handle.agent_id()).is_some());
1100        assert_eq!(new_handle.status(), AgentStatus::Created);
1101        // The restart_log should track the restart
1102        // Note: if the name is reused, the log is under the original id
1103        let log = supervisor.restart_log.read();
1104        assert!(log.values().any(|ts| !ts.is_empty()));
1105    }
1106
1107    #[tokio::test]
1108    async fn supervisor_restart_respects_max_restarts() {
1109        let policy = SupervisorPolicy {
1110            max_restarts: 1,
1111            restart_window_secs: 60,
1112            backoff: RestartBackoff::None,
1113        };
1114        let supervisor = AgentSupervisor::with_policy(
1115            mock_resolver(),
1116            Arc::new(NoopStore) as Arc<dyn SnapshotStore>,
1117            policy,
1118        );
1119        let handle = supervisor.spawn(test_config()).unwrap();
1120        let id = handle.agent_id().to_string();
1121
1122        // First restart should succeed
1123        let first = supervisor.restart(&id).await.unwrap();
1124        let first_id = first.agent_id().to_string();
1125
1126        // Second restart should fail (max_restarts=1)
1127        assert!(!supervisor.can_restart(&first_id));
1128        let result = supervisor.restart(&first_id).await;
1129        assert!(result.is_err());
1130    }
1131}