Skip to main content

oxios_kernel/
supervisor.rs

1//! Supervisor: agent lifecycle management.
2//!
3//! The supervisor handles forking, executing, monitoring, and
4//! terminating agent instances. It is the "init" of Oxios.
5//!
6//! When an agent is forked and executed, the supervisor delegates
7//! the actual tool-calling loop to the [`AgentRuntime`].
8//!
9//! # Agent Pool & Session Persistence
10//!
11//! Agents are retained in an [`AgentPool`] after execution for:
12//! - **Session continuation** via `Agent::continue_with()` — multi-turn
13//!   conversations without re-creating the agent.
14//! - **State export/import** — serialize agent conversation history to
15//!   JSON for crash recovery, migration, or debugging.
16//! - **Provider rate limiting** — all agents share a [`ProviderPool`] to
17//!   respect per-provider RPM/concurrency limits.
18
19use anyhow::Result;
20use async_trait::async_trait;
21use chrono::Utc;
22use oxi_sdk::Agent;
23use oxios_ouroboros::Seed;
24use parking_lot::RwLock;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use tokio::task::JoinHandle;
29
30use crate::agent_runtime::AgentRuntime;
31use crate::config::AgentLogConfig;
32use crate::event_bus::EventBus;
33use crate::resource_monitor::ResourceMonitor;
34use crate::session_context::SessionContext;
35use crate::state_store::StateStore;
36use crate::types::{AgentId, AgentInfo, AgentStatus};
37use oxios_ouroboros::ExecutionResult;
38
39#[cfg(feature = "sqlite-memory")]
40use crate::agent_log_db::AgentLogDb;
41
42/// Tracks the runtime handles needed to cancel a running agent.
43struct AgentHandle {
44    /// Flag set on `kill()` to cooperatively signal cancellation.
45    cancelled: Arc<AtomicBool>,
46    /// The tokio task running the agent execution. Aborted on `kill()`.
47    task: JoinHandle<Result<ExecutionResult>>,
48}
49
50/// Pool of live `Agent` instances, keyed by AgentId.
51///
52/// Retains agents after execution for:
53/// - **State persistence** — `export_state()` serializes conversation history
54///   to JSON for crash recovery, migration, or debugging.
55/// - **State restoration** — `import_state()` restores a previous session.
56#[derive(Default)]
57pub struct AgentPool {
58    agents: RwLock<HashMap<AgentId, Arc<Agent>>>,
59}
60
61impl AgentPool {
62    /// Create an empty agent pool.
63    pub fn new() -> Self {
64        Self {
65            agents: RwLock::new(HashMap::new()),
66        }
67    }
68
69    /// Insert an agent into the pool.
70    pub fn insert(&self, id: AgentId, agent: Arc<Agent>) {
71        self.agents.write().insert(id, agent);
72    }
73
74    /// Get a pooled agent by ID.
75    pub fn get(&self, id: &AgentId) -> Option<Arc<Agent>> {
76        self.agents.read().get(id).cloned()
77    }
78
79    /// Remove an agent from the pool.
80    pub fn remove(&self, id: &AgentId) -> Option<Arc<Agent>> {
81        self.agents.write().remove(id)
82    }
83
84    /// Export an agent's state as JSON.
85    ///
86    /// Returns `None` if the agent is not in the pool or export fails.
87    pub fn export_state(&self, id: &AgentId) -> Option<serde_json::Value> {
88        self.agents
89            .read()
90            .get(id)
91            .and_then(|agent| agent.export_state().ok())
92    }
93
94    /// Import agent state from JSON.
95    ///
96    /// Returns `false` if the agent is not in the pool or import fails.
97    pub fn import_state(&self, id: &AgentId, state: serde_json::Value) -> bool {
98        if let Some(agent) = self.agents.read().get(id) {
99            agent.import_state(state).is_ok()
100        } else {
101            false
102        }
103    }
104
105    /// Number of agents currently in the pool.
106    pub fn len(&self) -> usize {
107        self.agents.read().len()
108    }
109
110    /// Whether the pool is empty.
111    pub fn is_empty(&self) -> bool {
112        self.agents.read().is_empty()
113    }
114}
115
116/// Supervisor trait for managing agent lifecycles.
117#[async_trait]
118pub trait Supervisor: Send + Sync {
119    /// Fork a new agent from a seed specification.
120    async fn fork(&self, spec: &Seed) -> Result<AgentId>;
121
122    /// Start executing an agent.
123    async fn exec(&self, id: AgentId) -> Result<()>;
124
125    /// Fork and execute an agent with its seed, running to completion.
126    /// Returns the execution result from the agent runtime.
127    async fn run_with_seed(&self, id: AgentId, seed: &Seed) -> Result<ExecutionResult>;
128
129    /// Wait for an agent to complete and return its final status.
130    async fn wait(&self, id: AgentId) -> Result<AgentStatus>;
131
132    /// Terminate an agent.
133    async fn kill(&self, id: AgentId) -> Result<()>;
134
135    /// List all known agents.
136    async fn list(&self) -> Result<Vec<AgentInfo>>;
137}
138
139/// Basic in-memory supervisor implementation with AgentRuntime integration.
140pub struct BasicSupervisor {
141    agents: RwLock<HashMap<AgentId, AgentInfo>>,
142    /// Per-agent cancellation tokens and join handles for task abortion.
143    handles: RwLock<HashMap<AgentId, AgentHandle>>,
144    /// Pool of live Agent instances for session continuation.
145    agent_pool: AgentPool,
146    event_bus: EventBus,
147    runtime: Arc<AgentRuntime>,
148    resource_monitor: Option<Arc<ResourceMonitor>>,
149    /// Session context for proactive recall timing (RFC-020).
150    /// Shared across all Seed executions within this supervisor's lifetime
151    /// so that RecallTiming can track message count and topic changes.
152    /// Uses tokio::sync::RwLock (not parking_lot) so the guard is Send,
153    /// allowing it to be held across .await in tokio::spawn.
154    session_context: Arc<tokio::sync::RwLock<SessionContext>>,
155    /// Filesystem state store for agent persistence (JSON files).
156    state_store: Option<Arc<StateStore>>,
157    /// SQLite-backed agent history query index.
158    #[cfg(feature = "sqlite-memory")]
159    agent_log_db: Option<Arc<AgentLogDb>>,
160    /// Agent log retention configuration.
161    agent_log_config: AgentLogConfig,
162}
163
164impl BasicSupervisor {
165    /// Creates a new supervisor with the given event bus and agent runtime.
166    pub fn new(event_bus: EventBus, runtime: AgentRuntime) -> Self {
167        Self {
168            agents: RwLock::new(HashMap::new()),
169            handles: RwLock::new(HashMap::new()),
170            agent_pool: AgentPool::new(),
171            event_bus,
172            runtime: Arc::new(runtime),
173            resource_monitor: None,
174            session_context: Arc::new(tokio::sync::RwLock::new(SessionContext::new())),
175            state_store: None,
176            #[cfg(feature = "sqlite-memory")]
177            agent_log_db: None,
178            agent_log_config: AgentLogConfig::default(),
179        }
180    }
181
182    /// Attach a filesystem state store for agent history persistence.
183    pub fn set_state_store(&mut self, store: Arc<StateStore>) {
184        self.state_store = Some(store);
185    }
186
187    /// Attach a SQLite-backed agent history log database.
188    #[cfg(feature = "sqlite-memory")]
189    pub fn set_agent_log_db(&mut self, db: Arc<AgentLogDb>) {
190        self.agent_log_db = Some(db);
191    }
192
193    /// Set agent log retention configuration.
194    pub fn set_agent_log_config(&mut self, config: AgentLogConfig) {
195        self.agent_log_config = config;
196    }
197
198    /// Attach a resource monitor for agent count tracking.
199    pub fn set_resource_monitor(&mut self, rm: Arc<ResourceMonitor>) {
200        self.resource_monitor = Some(rm);
201    }
202
203    /// Update the resource monitor with current active agent count.
204    fn update_agent_count(&self) {
205        if let Some(ref rm) = self.resource_monitor {
206            let count = self.agents.read().len();
207            rm.set_active_agents(count);
208        }
209    }
210
211    /// Access the agent pool for session continuation.
212    pub fn pool(&self) -> &AgentPool {
213        &self.agent_pool
214    }
215}
216
217#[async_trait]
218impl Supervisor for BasicSupervisor {
219    async fn fork(&self, spec: &Seed) -> Result<AgentId> {
220        let id = AgentId::new_v4();
221        let info = AgentInfo {
222            id,
223            name: spec.goal.clone(),
224            status: AgentStatus::Starting,
225            created_at: Utc::now(),
226            seed_id: Some(spec.id),
227            project_id: spec.project_id,
228            started_at: None,
229            completed_at: None,
230            error: None,
231            steps_completed: 0,
232            steps_total: None,
233            tool_calls: vec![],
234            tokens_input: 0,
235            tokens_output: 0,
236            cost_usd: 0.0,
237            model_id: String::new(),
238            session_id: None,
239        };
240
241        {
242            let mut agents = self.agents.write();
243            agents.insert(id, info);
244        }
245
246        self.update_agent_count();
247
248        let _ = self
249            .event_bus
250            .publish(crate::event_bus::KernelEvent::AgentCreated {
251                id,
252                name: spec.goal.clone(),
253            });
254
255        tracing::info!(agent_id = %id, "Forked new agent from seed");
256        Ok(id)
257    }
258
259    async fn exec(&self, id: AgentId) -> Result<()> {
260        {
261            let mut agents = self.agents.write();
262            match agents.get_mut(&id) {
263                Some(agent) => {
264                    agent.status = AgentStatus::Running;
265                }
266                None => anyhow::bail!("Agent {id} not found"),
267            }
268        }
269
270        self.update_agent_count();
271
272        let _ = self
273            .event_bus
274            .publish(crate::event_bus::KernelEvent::AgentStarted { id });
275        tracing::info!(agent_id = %id, "Agent execution started");
276
277        Ok(())
278    }
279
280    async fn run_with_seed(&self, id: AgentId, seed: &Seed) -> Result<ExecutionResult> {
281        // Mark as running.
282        {
283            let mut agents = self.agents.write();
284            match agents.get_mut(&id) {
285                Some(agent) => {
286                    agent.status = AgentStatus::Running;
287                    agent.started_at = Some(Utc::now());
288                }
289                None => anyhow::bail!("Agent {id} not found"),
290            }
291        }
292
293        let _ = self
294            .event_bus
295            .publish(crate::event_bus::KernelEvent::AgentStarted { id });
296
297        tracing::info!(agent_id = %id, seed_id = %seed.id, "Running agent task");
298
299        // Spawn the execution as a tokio task so we can track and abort it.
300        let cancelled = Arc::new(AtomicBool::new(false));
301        let runtime = Arc::clone(&self.runtime);
302        let seed = seed.clone();
303        let cancelled_clone = cancelled.clone();
304
305        // Share the session context so RecallTiming persists across Seeds.
306        // Uses tokio::sync::RwLock so the guard is Send-safe across .await.
307        let session_ctx = self.session_context.clone();
308
309        let handle: JoinHandle<Result<ExecutionResult>> = tokio::spawn(async move {
310            // Check for cancellation before starting.
311            if cancelled_clone.load(Ordering::Relaxed) {
312                return Ok(ExecutionResult {
313                    output: "Agent cancelled before execution".into(),
314                    steps_completed: 0,
315                    success: false,
316                    tool_calls: vec![],
317                    tokens_input: 0,
318                    tokens_output: 0,
319                    model_id: String::new(),
320                });
321            }
322            let mut ctx = session_ctx.write().await;
323            runtime.execute(id, &seed, &mut ctx).await
324        });
325
326        // Store the handle so kill() can abort the task.
327        {
328            let mut handles = self.handles.write();
329            handles.insert(
330                id,
331                AgentHandle {
332                    cancelled,
333                    task: handle,
334                },
335            );
336        }
337
338        // Await the spawned task.
339        let result = {
340            let agent_handle = {
341                let mut handles = self.handles.write();
342                handles.remove(&id)
343            };
344            // Guard is dropped above, safe to await.
345
346            match agent_handle {
347                Some(ah) => match ah.task.await {
348                    Ok(res) => res,
349                    Err(join_err) => {
350                        // Task was aborted (e.g. kill()) or panicked.
351                        tracing::warn!(agent_id = %id, error = %join_err, "Agent task join error");
352                        Ok(ExecutionResult {
353                            output: format!("Agent task aborted: {join_err}"),
354                            steps_completed: 0,
355                            success: false,
356                            tool_calls: vec![],
357                            tokens_input: 0,
358                            tokens_output: 0,
359                            model_id: String::new(),
360                        })
361                    }
362                },
363                None => anyhow::bail!("Agent {id} handle disappeared"),
364            }
365        };
366
367        match result {
368            Ok(result) => {
369                tracing::info!(
370                    agent_id = %id,
371                    success = result.success,
372                    steps = result.steps_completed,
373                    "Agent task completed"
374                );
375
376                {
377                    let mut agents = self.agents.write();
378                    if let Some(agent) = agents.get_mut(&id) {
379                        agent.status = if result.success {
380                            AgentStatus::Idle
381                        } else {
382                            AgentStatus::Failed
383                        };
384                        agent.completed_at = Some(Utc::now());
385                        agent.steps_completed = result.steps_completed;
386                        agent.tool_calls = result
387                            .tool_calls
388                            .iter()
389                            .map(|tc| crate::types::ToolCallRecord {
390                                tool: tc.tool.clone(),
391                                input: tc.input.clone(),
392                                output: tc.output.clone(),
393                                duration_ms: tc.duration_ms,
394                                is_error: tc.is_error,
395                                tool_call_id: tc.tool_call_id.clone(),
396                                timestamp: tc.timestamp,
397                            })
398                            .collect();
399                        agent.tokens_input = result.tokens_input;
400                        agent.tokens_output = result.tokens_output;
401                        agent.model_id = result.model_id.clone();
402                        agent.cost_usd = if !result.model_id.is_empty() {
403                            crate::kernel_handle::engine_api::estimate_cost(
404                                &result.model_id,
405                                result.tokens_input,
406                                result.tokens_output,
407                            )
408                        } else {
409                            0.0
410                        };
411                        if !result.success {
412                            agent.error = Some(result.output.clone());
413                        }
414                    }
415                }
416
417                let _ = self
418                    .event_bus
419                    .publish(crate::event_bus::KernelEvent::AgentStopped { id });
420                self.update_agent_count();
421
422                // Persist to agent history log (async, non-blocking)
423                self.persist_agent(id).await;
424
425                Ok(result)
426            }
427            Err(e) => {
428                tracing::error!(agent_id = %id, error = %e, "Agent task failed");
429
430                {
431                    let mut agents = self.agents.write();
432                    if let Some(agent) = agents.get_mut(&id) {
433                        agent.status = AgentStatus::Failed;
434                        agent.completed_at = Some(Utc::now());
435                        agent.error = Some(e.to_string());
436                    }
437                }
438
439                let _ = self
440                    .event_bus
441                    .publish(crate::event_bus::KernelEvent::AgentFailed {
442                        id,
443                        error: e.to_string(),
444                    });
445                self.update_agent_count();
446
447                // Persist to agent history log (async, non-blocking)
448                self.persist_agent(id).await;
449
450                Ok(ExecutionResult {
451                    output: format!("Agent failed: {e}"),
452                    steps_completed: 0,
453                    success: false,
454                    tool_calls: vec![],
455                    tokens_input: 0,
456                    tokens_output: 0,
457                    model_id: String::new(),
458                })
459            }
460        }
461    }
462
463    async fn wait(&self, id: AgentId) -> Result<AgentStatus> {
464        let agents = self.agents.read();
465        match agents.get(&id) {
466            Some(info) => Ok(info.status),
467            None => anyhow::bail!("Agent {id} not found"),
468        }
469    }
470
471    async fn kill(&self, id: AgentId) -> Result<()> {
472        // Cancel and abort the running task, if any.
473        {
474            let mut handles = self.handles.write();
475            if let Some(agent_handle) = handles.remove(&id) {
476                agent_handle.cancelled.store(true, Ordering::Relaxed);
477                agent_handle.task.abort();
478                tracing::info!(agent_id = %id, "Agent task aborted");
479            }
480        }
481
482        {
483            let mut agents = self.agents.write();
484            if let Some(agent) = agents.get_mut(&id) {
485                agent.status = AgentStatus::Stopped;
486                agent.completed_at = Some(Utc::now());
487            } else {
488                anyhow::bail!("Agent {id} not found");
489            }
490        }
491
492        let _ = self
493            .event_bus
494            .publish(crate::event_bus::KernelEvent::AgentStopped { id });
495        self.update_agent_count();
496
497        // Persist to agent history log (async, non-blocking)
498        self.persist_agent(id).await;
499
500        tracing::info!(agent_id = %id, "Agent killed");
501        Ok(())
502    }
503
504    async fn list(&self) -> Result<Vec<AgentInfo>> {
505        let agents = self.agents.read();
506        Ok(agents.values().cloned().collect())
507    }
508}
509
510impl BasicSupervisor {
511    /// Persist a terminated agent to both filesystem JSON and SQLite.
512    /// Non-blocking: spawns a tokio task for the actual persistence.
513    async fn persist_agent(&self, id: AgentId) {
514        // Snapshot the agent info from the in-memory map
515        let info = {
516            let agents = self.agents.read();
517            agents.get(&id).cloned()
518        };
519
520        let Some(info) = info else { return };
521
522        // 1. Filesystem JSON (source of truth)
523        if let Some(ref store) = self.state_store {
524            let store = store.clone();
525            let info = info.clone();
526            let max_entries = self.agent_log_config.max_entries;
527            let ttl_hours = self.agent_log_config.ttl_hours;
528            let batch_size = self.agent_log_config.prune_batch_size;
529            tokio::spawn(async move {
530                let _ = store
531                    .save_json("agents", &id.to_string(), &info)
532                    .await
533                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to persist agent to filesystem"));
534
535                // Prune old records (async, best-effort)
536                if max_entries > 0 || ttl_hours > 0 {
537                    let _ = store
538                        .prune_agents_by_config(max_entries, ttl_hours, batch_size)
539                        .await
540                        .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent log"));
541                }
542            });
543        }
544
545        // 2. SQLite (query index)
546        #[cfg(feature = "sqlite-memory")]
547        if let Some(ref db) = self.agent_log_db {
548            let db = db.clone();
549            let info = info.clone();
550            let config = self.agent_log_config.clone();
551            tokio::spawn(async move {
552                let _ = db
553                    .upsert_agent(&info)
554                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to upsert agent to SQLite"));
555
556                // Prune old records
557                let _ = db
558                    .prune(&config)
559                    .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent SQLite"));
560            });
561        }
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::event_bus::EventBus;
569    use crate::types::AgentStatus;
570    // Note: imports kept for potential future test extensions.
571    use oxios_ouroboros::Seed;
572
573    // Note: MockProvider no longer needed — OxiosEngine handles provider resolution.
574    // The engine resolves models internally, so tests just use OxiosEngine::new().
575
576    /// Helper to create a real BasicSupervisor wired to a real EventBus.
577    async fn make_supervisor() -> BasicSupervisor {
578        let event_bus = EventBus::new(64);
579
580        // Build a mock KernelHandle with temp dirs.
581        let tmp = std::env::temp_dir().join(format!("oxios-test-{}", uuid::Uuid::new_v4()));
582        let _ = std::fs::create_dir_all(&tmp);
583
584        let state_store_2 =
585            Arc::new(crate::state_store::StateStore::new(tmp.join("state")).expect("state store"));
586        let state_store = state_store_2.clone();
587        let memory_manager = Arc::new({
588            let mut mm = crate::memory::MemoryManager::new(state_store.clone());
589            mm.set_git_layer(Arc::new(
590                crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
591            ));
592            mm
593        });
594
595        let kernel_handle = Arc::new(crate::KernelHandle::new(
596            crate::kernel_handle::StateApi::new(state_store),
597            crate::kernel_handle::AgentApi::new(
598                Arc::new(crate::supervisor::NoOpSupervisor),
599                Arc::new(crate::budget::BudgetManager::new()),
600                memory_manager.clone(),
601                Some(event_bus.clone()),
602            ),
603            crate::kernel_handle::SecurityApi::new(
604                Arc::new(parking_lot::Mutex::new(crate::auth::AuthManager::new())),
605                Arc::new(oxi_sdk::observability::AuditTrail::new(100)),
606                Arc::new(parking_lot::Mutex::new(
607                    crate::access_manager::AccessManager::new(),
608                )),
609                Arc::new(
610                    crate::state_store::StateStore::new(tmp.join("state2")).expect("state store 2"),
611                ),
612            ),
613            crate::kernel_handle::PersonaApi::new(Arc::new(crate::persona::PersonaManager::new())),
614            crate::kernel_handle::ExtensionApi::new(Arc::new(crate::skill::SkillManager::new(
615                tmp.join("skills"),
616                tmp.join("share/skills"),
617            ))),
618            crate::kernel_handle::McpApi::new(Arc::new(crate::mcp::McpBridge::new())),
619            crate::kernel_handle::InfraApi::new(
620                Arc::new(
621                    crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
622                ),
623                Arc::new(crate::scheduler::AgentScheduler::new(4, 60, 300)),
624                Arc::new(crate::cron::CronScheduler::new(
625                    Arc::new(
626                        crate::state_store::StateStore::new(tmp.join("cron")).expect("cron state"),
627                    ),
628                    60,
629                )),
630                Arc::new(crate::resource_monitor::ResourceMonitor::new(60, 100)),
631                EventBus::new(64),
632                crate::config::OxiosConfig::default(),
633                std::time::Instant::now(),
634            ),
635            None,
636            crate::kernel_handle::ExecApi::new(
637                Arc::new(parking_lot::RwLock::new(
638                    crate::config::ExecConfig::default(),
639                )),
640                Arc::new(parking_lot::Mutex::new(
641                    crate::access_manager::AccessManager::new(),
642                )),
643            ),
644            crate::kernel_handle::A2aApi::new(Arc::new(crate::a2a::A2AProtocol::new(
645                EventBus::new(64),
646            ))),
647            crate::kernel_handle::EngineApi::new(
648                Arc::new(parking_lot::RwLock::new(
649                    crate::config::OxiosConfig::default(),
650                )),
651                tmp.join("config.toml"),
652                Arc::new(crate::kernel_handle::RoutingStats::new()),
653                Arc::new(crate::engine::EngineHandle::new(Arc::new(
654                    crate::OxiosEngine::new("anthropic/claude-sonnet-4-20250514"),
655                ))),
656            ),
657            Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
658            Arc::new(
659                crate::kernel_handle::KnowledgeLens::new(
660                    Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
661                    memory_manager.clone(),
662                )
663                .unwrap(),
664            ),
665            crate::kernel_handle::MarketplaceApi::new(
666                Arc::new(crate::skill::clawhub::ClawHubInstaller::new(
667                    tmp.join("skills"),
668                    tmp.join("state"),
669                    None,
670                )),
671                Arc::new(
672                    crate::skill::clawhub::ClawHubClient::new(None).expect("valid ClawHub client"),
673                ),
674                Arc::new(crate::skill::skills_sh::SkillsShInstaller::new(
675                    tmp.join("skills"),
676                    None,
677                    None,
678                )),
679                Arc::new(
680                    crate::skill::skills_sh::SkillsShClient::new(None, None)
681                        .expect("valid Skills.sh client"),
682                ),
683            ),
684            None, // calendar (not configured in test)
685            None, // email (not configured in test)
686        ));
687
688        let engine = crate::OxiosEngine::new("mock/model");
689        let engine_handle = Arc::new(crate::engine::EngineHandle::new(Arc::new(engine)));
690        let runtime = AgentRuntime::new(engine_handle, "mock/model", kernel_handle, None);
691        BasicSupervisor::new(event_bus, runtime)
692    }
693
694    /// Helper to create a minimal Seed for testing.
695    fn make_seed(goal: &str) -> Seed {
696        Seed {
697            id: uuid::Uuid::new_v4(),
698            goal: goal.to_string(),
699            constraints: vec![],
700            acceptance_criteria: vec![],
701            ontology: vec![],
702            created_at: chrono::Utc::now(),
703            generation: 0,
704            parent_seed_id: None,
705            cspace_hint: None,
706            original_request: String::new(),
707            output_schema: None,
708            project_id: None,
709            workspace_context: None,
710            mount_paths: Vec::new(),
711        }
712    }
713
714    #[tokio::test]
715    async fn test_fork_creates_agent() {
716        let supervisor = make_supervisor().await;
717        let seed = make_seed("Test agent");
718
719        let id = supervisor.fork(&seed).await.unwrap();
720
721        let agents = supervisor.list().await.unwrap();
722        assert_eq!(agents.len(), 1);
723        assert_eq!(agents[0].id, id);
724        assert_eq!(agents[0].name, "Test agent");
725        assert_eq!(agents[0].status, AgentStatus::Starting);
726        assert_eq!(agents[0].seed_id, Some(seed.id));
727    }
728
729    #[tokio::test]
730    async fn test_exec_updates_status_to_running() {
731        let supervisor = make_supervisor().await;
732        let seed = make_seed("Running agent");
733
734        let id = supervisor.fork(&seed).await.unwrap();
735        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Starting);
736
737        supervisor.exec(id).await.unwrap();
738        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
739    }
740
741    #[tokio::test]
742    async fn test_kill_sets_stopped() {
743        let supervisor = make_supervisor().await;
744        let seed = make_seed("Doomed agent");
745
746        let id = supervisor.fork(&seed).await.unwrap();
747        supervisor.exec(id).await.unwrap();
748        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
749
750        supervisor.kill(id).await.unwrap();
751        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Stopped);
752    }
753
754    #[tokio::test]
755    async fn test_kill_unknown_agent_returns_error() {
756        let supervisor = make_supervisor().await;
757        let unknown_id = uuid::Uuid::new_v4();
758
759        let result = supervisor.kill(unknown_id).await;
760        assert!(result.is_err());
761        assert!(result.unwrap_err().to_string().contains("not found"));
762    }
763
764    #[tokio::test]
765    async fn test_list_returns_all_agents() {
766        let supervisor = make_supervisor().await;
767
768        let id1 = supervisor.fork(&make_seed("Agent 1")).await.unwrap();
769        let id2 = supervisor.fork(&make_seed("Agent 2")).await.unwrap();
770        let id3 = supervisor.fork(&make_seed("Agent 3")).await.unwrap();
771
772        let agents = supervisor.list().await.unwrap();
773        assert_eq!(agents.len(), 3);
774
775        let ids: std::collections::HashSet<AgentId> = agents.iter().map(|a| a.id).collect();
776        assert!(ids.contains(&id1));
777        assert!(ids.contains(&id2));
778        assert!(ids.contains(&id3));
779    }
780
781    #[tokio::test]
782    async fn test_exec_unknown_agent_returns_error() {
783        let supervisor = make_supervisor().await;
784        let unknown_id = uuid::Uuid::new_v4();
785
786        let result = supervisor.exec(unknown_id).await;
787        assert!(result.is_err());
788        assert!(result.unwrap_err().to_string().contains("not found"));
789    }
790
791    #[tokio::test]
792    async fn test_wait_unknown_agent_returns_error() {
793        let supervisor = make_supervisor().await;
794        let unknown_id = uuid::Uuid::new_v4();
795
796        let result = supervisor.wait(unknown_id).await;
797        assert!(result.is_err());
798        assert!(result.unwrap_err().to_string().contains("not found"));
799    }
800}
801
802/// A no-op supervisor used during KernelBuilder::build() to break the
803/// KernelHandle → AgentRuntime → Supervisor → KernelHandle cycle.
804///
805/// AgentApi.supervisor is only used for list/kill operations, not during
806/// tool registration, so this placeholder is safe during build time.
807pub struct NoOpSupervisor;
808
809#[async_trait::async_trait]
810impl Supervisor for NoOpSupervisor {
811    async fn fork(&self, _spec: &Seed) -> Result<AgentId> {
812        Err(anyhow::anyhow!(
813            "NoOpSupervisor: fork not available during build"
814        ))
815    }
816    async fn exec(&self, _id: AgentId) -> Result<()> {
817        Err(anyhow::anyhow!(
818            "NoOpSupervisor: exec not available during build"
819        ))
820    }
821    async fn run_with_seed(&self, _id: AgentId, _seed: &Seed) -> Result<ExecutionResult> {
822        Err(anyhow::anyhow!(
823            "NoOpSupervisor: run_with_seed not available during build"
824        ))
825    }
826    async fn wait(&self, _id: AgentId) -> Result<AgentStatus> {
827        Err(anyhow::anyhow!(
828            "NoOpSupervisor: wait not available during build"
829        ))
830    }
831    async fn kill(&self, _id: AgentId) -> Result<()> {
832        Err(anyhow::anyhow!(
833            "NoOpSupervisor: kill not available during build"
834        ))
835    }
836    async fn list(&self) -> Result<Vec<AgentInfo>> {
837        Ok(Vec::new())
838    }
839}