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