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::{Directive, ExecEnv};
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<()>,
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    /// Start executing an agent.
120    async fn exec(&self, id: AgentId) -> Result<()>;
121
122    /// Fork a new agent from a Directive and ExecEnv (RFC-027).
123    ///
124    /// Reads the goal / project_id from the unified-intent types. The
125    /// resulting agent has no `seed_id` (a Directive has no stable
126    /// per-execution UUID yet — Phase 6 will mint one in
127    /// `Orchestrator::handle()`).
128    async fn fork_directive(&self, directive: &Directive, env: &ExecEnv) -> Result<AgentId>;
129
130    /// Fork and execute an agent with a Directive + ExecEnv, running to completion.
131    ///
132    /// Dispatches to `AgentRuntime::execute_directive_with_session`.
133    async fn run_with_directive(
134        &self,
135        id: AgentId,
136        directive: &Directive,
137        env: &ExecEnv,
138    ) -> Result<ExecutionResult>;
139
140    /// Wait for an agent to complete and return its final status.
141    async fn wait(&self, id: AgentId) -> Result<AgentStatus>;
142
143    /// Terminate an agent.
144    async fn kill(&self, id: AgentId) -> Result<()>;
145
146    /// List all known agents.
147    async fn list(&self) -> Result<Vec<AgentInfo>>;
148}
149
150/// Basic in-memory supervisor implementation with AgentRuntime integration.
151pub struct BasicSupervisor {
152    agents: RwLock<HashMap<AgentId, AgentInfo>>,
153    /// Per-agent cancellation tokens and join handles for task abortion.
154    handles: RwLock<HashMap<AgentId, AgentHandle>>,
155    /// Pool of live Agent instances for session continuation.
156    agent_pool: AgentPool,
157    event_bus: EventBus,
158    runtime: Arc<AgentRuntime>,
159    resource_monitor: Option<Arc<ResourceMonitor>>,
160    /// Session context for proactive recall timing (RFC-020).
161    /// Shared across all agent executions within this supervisor's lifetime
162    /// so that RecallTiming can track message count and topic changes.
163    /// Uses tokio::sync::RwLock (not parking_lot) so the guard is Send,
164    /// allowing it to be held across .await in tokio::spawn.
165    session_context: Arc<tokio::sync::RwLock<SessionContext>>,
166    /// Filesystem state store for agent persistence (JSON files).
167    state_store: Option<Arc<StateStore>>,
168    /// SQLite-backed agent history query index.
169    #[cfg(feature = "sqlite-memory")]
170    agent_log_db: Option<Arc<AgentLogDb>>,
171    /// Agent log retention configuration.
172    agent_log_config: AgentLogConfig,
173}
174
175impl BasicSupervisor {
176    /// Creates a new supervisor with the given event bus and agent runtime.
177    pub fn new(event_bus: EventBus, runtime: AgentRuntime) -> Self {
178        Self {
179            agents: RwLock::new(HashMap::new()),
180            handles: RwLock::new(HashMap::new()),
181            agent_pool: AgentPool::new(),
182            event_bus,
183            runtime: Arc::new(runtime),
184            resource_monitor: None,
185            session_context: Arc::new(tokio::sync::RwLock::new(SessionContext::new())),
186            state_store: None,
187            #[cfg(feature = "sqlite-memory")]
188            agent_log_db: None,
189            agent_log_config: AgentLogConfig::default(),
190        }
191    }
192
193    /// Attach a filesystem state store for agent history persistence.
194    pub fn set_state_store(&mut self, store: Arc<StateStore>) {
195        self.state_store = Some(store);
196    }
197
198    /// Attach a SQLite-backed agent history log database.
199    #[cfg(feature = "sqlite-memory")]
200    pub fn set_agent_log_db(&mut self, db: Arc<AgentLogDb>) {
201        self.agent_log_db = Some(db);
202    }
203
204    /// Set agent log retention configuration.
205    pub fn set_agent_log_config(&mut self, config: AgentLogConfig) {
206        self.agent_log_config = config;
207    }
208
209    /// Attach a resource monitor for agent count tracking.
210    pub fn set_resource_monitor(&mut self, rm: Arc<ResourceMonitor>) {
211        self.resource_monitor = Some(rm);
212    }
213
214    /// Update the resource monitor with current active agent count.
215    fn update_agent_count(&self) {
216        if let Some(ref rm) = self.resource_monitor {
217            let count = self.agents.read().len();
218            rm.set_active_agents(count);
219        }
220    }
221
222    /// Access the agent pool for session continuation.
223    pub fn pool(&self) -> &AgentPool {
224        &self.agent_pool
225    }
226}
227
228#[async_trait]
229impl Supervisor for BasicSupervisor {
230    async fn fork_directive(&self, directive: &Directive, env: &ExecEnv) -> Result<AgentId> {
231        let id = AgentId::new_v4();
232        let info = AgentInfo {
233            id,
234            name: directive.goal.clone(),
235            status: AgentStatus::Starting,
236            created_at: Utc::now(),
237            // Directive has no per-execution UUID yet (Phase 6). Leave
238            // seed_id None to mark this as a directive-spawned agent.
239            seed_id: None,
240            project_id: env.project_id,
241            started_at: None,
242            completed_at: None,
243            error: None,
244            steps_completed: 0,
245            steps_total: None,
246            tool_calls: vec![],
247            tokens_input: 0,
248            tokens_output: 0,
249            cost_usd: 0.0,
250            model_id: String::new(),
251            session_id: None,
252        };
253
254        {
255            let mut agents = self.agents.write();
256            agents.insert(id, info);
257        }
258
259        self.update_agent_count();
260
261        let _ = self
262            .event_bus
263            .publish(crate::event_bus::KernelEvent::AgentCreated {
264                id,
265                name: directive.goal.clone(),
266            });
267
268        tracing::info!(agent_id = %id, "Forked new agent from directive");
269        Ok(id)
270    }
271
272    async fn exec(&self, id: AgentId) -> Result<()> {
273        {
274            let mut agents = self.agents.write();
275            match agents.get_mut(&id) {
276                Some(agent) => {
277                    agent.status = AgentStatus::Running;
278                }
279                None => anyhow::bail!("Agent {id} not found"),
280            }
281        }
282
283        self.update_agent_count();
284
285        let _ = self
286            .event_bus
287            .publish(crate::event_bus::KernelEvent::AgentStarted { id });
288        tracing::info!(agent_id = %id, "Agent execution started");
289
290        Ok(())
291    }
292
293    async fn run_with_directive(
294        &self,
295        id: AgentId,
296        directive: &Directive,
297        env: &ExecEnv,
298    ) -> Result<ExecutionResult> {
299        // Mark as running.
300        {
301            let mut agents = self.agents.write();
302            match agents.get_mut(&id) {
303                Some(agent) => {
304                    agent.status = AgentStatus::Running;
305                    agent.started_at = Some(Utc::now());
306                }
307                None => anyhow::bail!("Agent {id} not found"),
308            }
309        }
310
311        let _ = self
312            .event_bus
313            .publish(crate::event_bus::KernelEvent::AgentStarted { id });
314
315        tracing::info!(agent_id = %id, "Running agent task from directive");
316
317        // Spawn the execution as a tokio task so we can track and abort it.
318        let cancelled = Arc::new(AtomicBool::new(false));
319        let runtime = Arc::clone(&self.runtime);
320        let directive = directive.clone();
321        let env = env.clone();
322
323        // Share the session context so RecallTiming persists across directives.
324        // Uses tokio::sync::RwLock so the guard is Send-safe across .await.
325        let session_ctx = self.session_context.clone();
326
327        let (done_tx, done_rx) = tokio::sync::oneshot::channel::<Result<ExecutionResult>>();
328        let cancelled_done = cancelled.clone();
329        let handle: JoinHandle<()> = tokio::spawn(async move {
330            // Check for cancellation before starting.
331            let result = if cancelled_done.load(Ordering::Relaxed) {
332                Ok(ExecutionResult {
333                    output: "Agent cancelled before execution".into(),
334                    steps_completed: 0,
335                    success: false,
336                    tool_calls: vec![],
337                    tokens_input: 0,
338                    tokens_output: 0,
339                    model_id: String::new(),
340                })
341            } else {
342                let mut ctx = session_ctx.write().await;
343                runtime
344                    .execute_directive(id, &directive, &env, &mut ctx)
345                    .await
346            };
347            // Receiver gone (run_with_directive returned early) → ignore error.
348            let _ = done_tx.send(result);
349        });
350
351        // Store the handle so kill() can abort the task.
352        {
353            let mut handles = self.handles.write();
354            handles.insert(
355                id,
356                AgentHandle {
357                    cancelled,
358                    task: handle,
359                },
360            );
361        }
362
363        // Await completion via the oneshot channel. If kill() aborts the task
364        // (or it panics), done_tx is dropped and this returns Err — treat as
365        // cancellation.
366        let result = match done_rx.await {
367            Ok(res) => res,
368            Err(_) => {
369                let mut handles = self.handles.write();
370                handles.remove(&id);
371                Ok(ExecutionResult {
372                    output: "Agent task aborted".into(),
373                    steps_completed: 0,
374                    success: false,
375                    tool_calls: vec![],
376                    tokens_input: 0,
377                    tokens_output: 0,
378                    model_id: String::new(),
379                })
380            }
381        };
382
383        // Natural completion — remove the handle.
384        {
385            let mut handles = self.handles.write();
386            handles.remove(&id);
387        }
388
389        match result {
390            Ok(result) => {
391                tracing::info!(
392                    agent_id = %id,
393                    success = result.success,
394                    steps = result.steps_completed,
395                    "Agent task completed (directive)"
396                );
397
398                {
399                    let mut agents = self.agents.write();
400                    if let Some(agent) = agents.get_mut(&id) {
401                        agent.status = if result.success {
402                            AgentStatus::Idle
403                        } else {
404                            AgentStatus::Failed
405                        };
406                        agent.completed_at = Some(Utc::now());
407                        agent.steps_completed = result.steps_completed;
408                        agent.tool_calls = result
409                            .tool_calls
410                            .iter()
411                            .map(|tc| crate::types::ToolCallRecord {
412                                tool: tc.tool.clone(),
413                                input: tc.input.clone(),
414                                output: tc.output.clone(),
415                                duration_ms: tc.duration_ms,
416                                is_error: tc.is_error,
417                                tool_call_id: tc.tool_call_id.clone(),
418                                timestamp: tc.timestamp,
419                            })
420                            .collect();
421                        agent.tokens_input = result.tokens_input;
422                        agent.tokens_output = result.tokens_output;
423                        agent.model_id = result.model_id.clone();
424                        agent.cost_usd = if !result.model_id.is_empty() {
425                            crate::kernel_handle::engine_api::estimate_cost(
426                                &result.model_id,
427                                result.tokens_input,
428                                result.tokens_output,
429                            )
430                        } else {
431                            0.0
432                        };
433                        if !result.success {
434                            agent.error = Some(result.output.clone());
435                        }
436                    }
437                }
438
439                let _ = self
440                    .event_bus
441                    .publish(crate::event_bus::KernelEvent::AgentStopped {
442                        id,
443                        success: result.success,
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(result)
451            }
452            Err(e) => {
453                tracing::error!(agent_id = %id, error = %e, "Agent task failed (directive)");
454
455                {
456                    let mut agents = self.agents.write();
457                    if let Some(agent) = agents.get_mut(&id) {
458                        agent.status = AgentStatus::Failed;
459                        agent.completed_at = Some(Utc::now());
460                        agent.error = Some(e.to_string());
461                    }
462                }
463
464                let _ = self
465                    .event_bus
466                    .publish(crate::event_bus::KernelEvent::AgentFailed {
467                        id,
468                        error: e.to_string(),
469                    });
470                self.update_agent_count();
471
472                // Persist to agent history log (async, non-blocking)
473                self.persist_agent(id).await;
474
475                Ok(ExecutionResult {
476                    output: format!("Agent failed: {e}"),
477                    steps_completed: 0,
478                    success: false,
479                    tool_calls: vec![],
480                    tokens_input: 0,
481                    tokens_output: 0,
482                    model_id: String::new(),
483                })
484            }
485        }
486    }
487
488    async fn wait(&self, id: AgentId) -> Result<AgentStatus> {
489        let agents = self.agents.read();
490        match agents.get(&id) {
491            Some(info) => Ok(info.status),
492            None => anyhow::bail!("Agent {id} not found"),
493        }
494    }
495
496    async fn kill(&self, id: AgentId) -> Result<()> {
497        // Cancel and abort the running task, if any.
498        {
499            let mut handles = self.handles.write();
500            if let Some(agent_handle) = handles.remove(&id) {
501                agent_handle.cancelled.store(true, Ordering::Relaxed);
502                agent_handle.task.abort();
503                tracing::info!(agent_id = %id, "Agent task aborted");
504            }
505        }
506
507        {
508            let mut agents = self.agents.write();
509            if let Some(agent) = agents.get_mut(&id) {
510                agent.status = AgentStatus::Stopped;
511                agent.completed_at = Some(Utc::now());
512            } else {
513                anyhow::bail!("Agent {id} not found");
514            }
515        }
516
517        let _ = self
518            .event_bus
519            .publish(crate::event_bus::KernelEvent::AgentStopped { id, success: false });
520        self.update_agent_count();
521
522        // Persist to agent history log (async, non-blocking)
523        self.persist_agent(id).await;
524
525        tracing::info!(agent_id = %id, "Agent killed");
526        Ok(())
527    }
528
529    async fn list(&self) -> Result<Vec<AgentInfo>> {
530        let agents = self.agents.read();
531        Ok(agents.values().cloned().collect())
532    }
533}
534
535impl BasicSupervisor {
536    /// Persist a terminated agent to both filesystem JSON and SQLite.
537    /// Non-blocking: spawns a tokio task for the actual persistence.
538    async fn persist_agent(&self, id: AgentId) {
539        // Snapshot the agent info from the in-memory map
540        let info = {
541            let agents = self.agents.read();
542            agents.get(&id).cloned()
543        };
544
545        let Some(info) = info else { return };
546
547        // 1. Filesystem JSON (source of truth)
548        if let Some(ref store) = self.state_store {
549            let store = store.clone();
550            let info = info.clone();
551            let max_entries = self.agent_log_config.max_entries;
552            let ttl_hours = self.agent_log_config.ttl_hours;
553            let batch_size = self.agent_log_config.prune_batch_size;
554            tokio::spawn(async move {
555                let _ = store
556                    .save_json("agents", &id.to_string(), &info)
557                    .await
558                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to persist agent to filesystem"));
559
560                // Prune old records (async, best-effort)
561                if max_entries > 0 || ttl_hours > 0 {
562                    let _ = store
563                        .prune_agents_by_config(max_entries, ttl_hours, batch_size)
564                        .await
565                        .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent log"));
566                }
567            });
568        }
569
570        // 2. SQLite (query index)
571        #[cfg(feature = "sqlite-memory")]
572        if let Some(ref db) = self.agent_log_db {
573            let db = db.clone();
574            let info = info.clone();
575            let config = self.agent_log_config.clone();
576            tokio::spawn(async move {
577                let _ = db
578                    .upsert_agent(&info)
579                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to upsert agent to SQLite"));
580
581                // Prune old records
582                let _ = db
583                    .prune(&config)
584                    .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent SQLite"));
585            });
586        }
587    }
588}
589
590/// A no-op supervisor used during KernelBuilder::build() to break the
591/// KernelHandle → AgentRuntime → Supervisor → KernelHandle cycle.
592///
593/// AgentApi.supervisor is only used for list/kill operations, not during
594/// tool registration, so this placeholder is safe during build time.
595pub struct NoOpSupervisor;
596
597#[async_trait::async_trait]
598impl Supervisor for NoOpSupervisor {
599    async fn exec(&self, _id: AgentId) -> Result<()> {
600        Err(anyhow::anyhow!(
601            "NoOpSupervisor: exec not available during build"
602        ))
603    }
604    async fn fork_directive(&self, _directive: &Directive, _env: &ExecEnv) -> Result<AgentId> {
605        Err(anyhow::anyhow!(
606            "NoOpSupervisor: fork_directive not available during build"
607        ))
608    }
609    async fn run_with_directive(
610        &self,
611        _id: AgentId,
612        _directive: &Directive,
613        _env: &ExecEnv,
614    ) -> Result<ExecutionResult> {
615        Err(anyhow::anyhow!(
616            "NoOpSupervisor: run_with_directive not available during build"
617        ))
618    }
619    async fn wait(&self, _id: AgentId) -> Result<AgentStatus> {
620        Err(anyhow::anyhow!(
621            "NoOpSupervisor: wait not available during build"
622        ))
623    }
624    async fn kill(&self, _id: AgentId) -> Result<()> {
625        Err(anyhow::anyhow!(
626            "NoOpSupervisor: kill not available during build"
627        ))
628    }
629    async fn list(&self) -> Result<Vec<AgentInfo>> {
630        Ok(Vec::new())
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use crate::event_bus::EventBus;
638    use crate::types::AgentStatus;
639
640    // Note: MockProvider no longer needed — OxiosEngine handles provider resolution.
641    // The engine resolves models internally, so tests just use OxiosEngine::new().
642
643    /// Helper to create a real BasicSupervisor wired to a real EventBus.
644    async fn make_supervisor() -> BasicSupervisor {
645        let event_bus = EventBus::new(64);
646
647        // Build a mock KernelHandle with temp dirs.
648        let tmp = std::env::temp_dir().join(format!("oxios-test-{}", uuid::Uuid::new_v4()));
649        let _ = std::fs::create_dir_all(&tmp);
650
651        let state_store_2 =
652            Arc::new(crate::state_store::StateStore::new(tmp.join("state")).expect("state store"));
653        let state_store = state_store_2.clone();
654        let memory_manager = Arc::new({
655            let mut mm = crate::memory::MemoryManager::new(state_store.clone());
656            mm.set_git_layer(Arc::new(
657                crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
658            ));
659            mm
660        });
661
662        let kernel_handle = Arc::new(crate::KernelHandle::new(
663            crate::kernel_handle::StateApi::new(state_store),
664            crate::kernel_handle::AgentApi::new(
665                Arc::new(crate::supervisor::NoOpSupervisor),
666                Arc::new(crate::budget::BudgetManager::new()),
667                memory_manager.clone(),
668                Some(event_bus.clone()),
669            ),
670            crate::kernel_handle::SecurityApi::new(
671                Arc::new(parking_lot::Mutex::new(crate::auth::AuthManager::new())),
672                Arc::new(oxi_sdk::observability::AuditTrail::new(100)),
673                Arc::new(parking_lot::Mutex::new(
674                    crate::access_manager::AccessManager::new(),
675                )),
676                Arc::new(
677                    crate::state_store::StateStore::new(tmp.join("state2")).expect("state store 2"),
678                ),
679            ),
680            crate::kernel_handle::PersonaApi::new(Arc::new(crate::persona::PersonaManager::new())),
681            crate::kernel_handle::ExtensionApi::new(Arc::new(crate::skill::SkillManager::new(
682                tmp.join("skills"),
683                tmp.join("share/skills"),
684            ))),
685            crate::kernel_handle::McpApi::new(Arc::new(crate::mcp::McpBridge::new())),
686            crate::kernel_handle::InfraApi::new(
687                Arc::new(
688                    crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
689                ),
690                Arc::new(crate::scheduler::AgentScheduler::new(4, 60, 300)),
691                Arc::new(crate::cron::CronScheduler::new(
692                    Arc::new(
693                        crate::state_store::StateStore::new(tmp.join("cron")).expect("cron state"),
694                    ),
695                    60,
696                )),
697                Arc::new(crate::resource_monitor::ResourceMonitor::new(60, 100)),
698                EventBus::new(64),
699                crate::config::OxiosConfig::default(),
700                std::time::Instant::now(),
701            ),
702            None,
703            crate::kernel_handle::ExecApi::new(
704                Arc::new(parking_lot::RwLock::new(
705                    crate::config::ExecConfig::default(),
706                )),
707                Arc::new(parking_lot::Mutex::new(
708                    crate::access_manager::AccessManager::new(),
709                )),
710            ),
711            crate::kernel_handle::A2aApi::new(Arc::new(crate::a2a::A2AProtocol::new(
712                EventBus::new(64),
713            ))),
714            crate::kernel_handle::EngineApi::new(
715                Arc::new(parking_lot::RwLock::new(
716                    crate::config::OxiosConfig::default(),
717                )),
718                tmp.join("config.toml"),
719                Arc::new(crate::kernel_handle::RoutingStats::new()),
720                Arc::new(crate::engine::EngineHandle::new(Arc::new(
721                    crate::OxiosEngine::new("anthropic/claude-sonnet-4-20250514"),
722                ))),
723            ),
724            Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
725            Arc::new(
726                crate::kernel_handle::KnowledgeLens::new(
727                    Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
728                    memory_manager.clone(),
729                )
730                .unwrap(),
731            ),
732            crate::kernel_handle::MarketplaceApi::new(
733                Arc::new(crate::skill::clawhub::ClawHubInstaller::new(
734                    tmp.join("skills"),
735                    tmp.join("state"),
736                    None,
737                )),
738                Arc::new(
739                    crate::skill::clawhub::ClawHubClient::new(None).expect("valid ClawHub client"),
740                ),
741                Arc::new(crate::skill::skills_sh::SkillsShInstaller::new(
742                    tmp.join("skills"),
743                    None,
744                    None,
745                )),
746                Arc::new(
747                    crate::skill::skills_sh::SkillsShClient::new(None, None)
748                        .expect("valid Skills.sh client"),
749                ),
750            ),
751            None, // calendar (not configured in test)
752            None, // email (not configured in test)
753        ));
754
755        let engine = crate::OxiosEngine::new("mock/model");
756        let engine_handle = Arc::new(crate::engine::EngineHandle::new(Arc::new(engine)));
757        let runtime = AgentRuntime::new(engine_handle, kernel_handle, None);
758        BasicSupervisor::new(event_bus, runtime)
759    }
760
761    /// Helper to create a minimal (Directive, ExecEnv) pair for testing.
762    fn make_directive(goal: &str) -> (Directive, ExecEnv) {
763        (Directive::from_message(goal), ExecEnv::default())
764    }
765
766    #[tokio::test]
767    async fn test_fork_creates_agent() {
768        let supervisor = make_supervisor().await;
769        let (directive, env) = make_directive("Test agent");
770
771        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
772
773        let agents = supervisor.list().await.unwrap();
774        assert_eq!(agents.len(), 1);
775        assert_eq!(agents[0].id, id);
776        assert_eq!(agents[0].name, "Test agent");
777        assert_eq!(agents[0].status, AgentStatus::Starting);
778        assert_eq!(agents[0].seed_id, None);
779    }
780
781    #[tokio::test]
782    async fn test_exec_updates_status_to_running() {
783        let supervisor = make_supervisor().await;
784        let (directive, env) = make_directive("Running agent");
785
786        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
787        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Starting);
788
789        supervisor.exec(id).await.unwrap();
790        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
791    }
792
793    #[tokio::test]
794    async fn test_kill_sets_stopped() {
795        let supervisor = make_supervisor().await;
796        let (directive, env) = make_directive("Doomed agent");
797
798        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
799        supervisor.exec(id).await.unwrap();
800        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
801
802        supervisor.kill(id).await.unwrap();
803        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Stopped);
804    }
805
806    #[tokio::test]
807    async fn test_kill_unknown_agent_returns_error() {
808        let supervisor = make_supervisor().await;
809        let unknown_id = uuid::Uuid::new_v4();
810
811        let result = supervisor.kill(unknown_id).await;
812        assert!(result.is_err());
813        assert!(result.unwrap_err().to_string().contains("not found"));
814    }
815
816    #[tokio::test]
817    async fn test_list_returns_all_agents() {
818        let supervisor = make_supervisor().await;
819
820        let (d1, e1) = make_directive("Agent 1");
821        let id1 = supervisor.fork_directive(&d1, &e1).await.unwrap();
822        let (d2, e2) = make_directive("Agent 2");
823        let id2 = supervisor.fork_directive(&d2, &e2).await.unwrap();
824        let (d3, e3) = make_directive("Agent 3");
825        let id3 = supervisor.fork_directive(&d3, &e3).await.unwrap();
826
827        let agents = supervisor.list().await.unwrap();
828        assert_eq!(agents.len(), 3);
829
830        let ids: std::collections::HashSet<AgentId> = agents.iter().map(|a| a.id).collect();
831        assert!(ids.contains(&id1));
832        assert!(ids.contains(&id2));
833        assert!(ids.contains(&id3));
834    }
835
836    #[tokio::test]
837    async fn test_exec_unknown_agent_returns_error() {
838        let supervisor = make_supervisor().await;
839        let unknown_id = uuid::Uuid::new_v4();
840
841        let result = supervisor.exec(unknown_id).await;
842        assert!(result.is_err());
843        assert!(result.unwrap_err().to_string().contains("not found"));
844    }
845
846    #[tokio::test]
847    async fn test_wait_unknown_agent_returns_error() {
848        let supervisor = make_supervisor().await;
849        let unknown_id = uuid::Uuid::new_v4();
850
851        let result = supervisor.wait(unknown_id).await;
852        assert!(result.is_err());
853        assert!(result.unwrap_err().to_string().contains("not found"));
854    }
855}