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                })
345            } else {
346                let mut ctx = session_ctx.write().await;
347                runtime
348                    .execute_directive(id, &directive, &env, &mut ctx)
349                    .await
350            };
351            // Receiver gone (run_with_directive returned early) → ignore error.
352            let _ = done_tx.send(result);
353        });
354
355        // Store the handle so kill() can abort the task.
356        {
357            let mut handles = self.handles.write();
358            handles.insert(
359                id,
360                AgentHandle {
361                    cancelled,
362                    task: handle,
363                },
364            );
365        }
366
367        // Await completion via the oneshot channel. If kill() aborts the task
368        // (or it panics), done_tx is dropped and this returns Err — treat as
369        // cancellation.
370        let result = match done_rx.await {
371            Ok(res) => res,
372            Err(_) => {
373                let mut handles = self.handles.write();
374                handles.remove(&id);
375                Ok(ExecutionResult {
376                    output: "Agent task aborted".into(),
377                    steps_completed: 0,
378                    success: false,
379                    tool_calls: vec![],
380                    tokens_input: 0,
381                    tokens_output: 0,
382                    model_id: String::new(),
383                    failure_class: None, // abort (kill/panic), not a provider failure
384                    restore_state: None,
385                })
386            }
387        };
388
389        // Natural completion — remove the handle.
390        {
391            let mut handles = self.handles.write();
392            handles.remove(&id);
393        }
394
395        match result {
396            Ok(result) => {
397                tracing::info!(
398                    agent_id = %id,
399                    success = result.success,
400                    steps = result.steps_completed,
401                    "Agent task completed (directive)"
402                );
403
404                {
405                    let mut agents = self.agents.write();
406                    if let Some(agent) = agents.get_mut(&id) {
407                        agent.status = if result.success {
408                            AgentStatus::Idle
409                        } else {
410                            AgentStatus::Failed
411                        };
412                        agent.completed_at = Some(Utc::now());
413                        agent.steps_completed = result.steps_completed;
414                        agent.tool_calls = result
415                            .tool_calls
416                            .iter()
417                            .map(|tc| crate::types::ToolCallRecord {
418                                tool: tc.tool.clone(),
419                                input: tc.input.clone(),
420                                output: tc.output.clone(),
421                                duration_ms: tc.duration_ms,
422                                is_error: tc.is_error,
423                                tool_call_id: tc.tool_call_id.clone(),
424                                timestamp: tc.timestamp,
425                            })
426                            .collect();
427                        agent.tokens_input = result.tokens_input;
428                        agent.tokens_output = result.tokens_output;
429                        agent.model_id = result.model_id.clone();
430                        agent.cost_usd = if !result.model_id.is_empty() {
431                            crate::kernel_handle::engine_api::estimate_cost(
432                                &result.model_id,
433                                result.tokens_input,
434                                result.tokens_output,
435                            )
436                        } else {
437                            0.0
438                        };
439                        if !result.success {
440                            agent.error = Some(result.output.clone());
441                        }
442                    }
443                }
444
445                let _ = self
446                    .event_bus
447                    .publish(crate::event_bus::KernelEvent::AgentStopped {
448                        id,
449                        success: result.success,
450                    });
451                self.update_agent_count();
452
453                // Persist to agent history log (async, non-blocking)
454                self.persist_agent(id).await;
455
456                Ok(result)
457            }
458            Err(e) => {
459                tracing::error!(agent_id = %id, error = %e, "Agent task failed (directive)");
460
461                {
462                    let mut agents = self.agents.write();
463                    if let Some(agent) = agents.get_mut(&id) {
464                        agent.status = AgentStatus::Failed;
465                        agent.completed_at = Some(Utc::now());
466                        agent.error = Some(e.to_string());
467                    }
468                }
469
470                let _ = self
471                    .event_bus
472                    .publish(crate::event_bus::KernelEvent::AgentFailed {
473                        id,
474                        error: e.to_string(),
475                    });
476                self.update_agent_count();
477
478                // Persist to agent history log (async, non-blocking)
479                self.persist_agent(id).await;
480
481                Ok(ExecutionResult {
482                    output: format!("Agent failed: {e}"),
483                    steps_completed: 0,
484                    success: false,
485                    tool_calls: vec![],
486                    tokens_input: 0,
487                    tokens_output: 0,
488                    model_id: String::new(),
489                    // RFC-029 P0: classify the error so downstream
490                    // (P2 RecoveryCoordinator, gateway user-facing
491                    // messages) can see whether this is a transient
492                    // retry, a quota/auth that needs provider swap,
493                    // context overflow, etc. Conservative: Unknown
494                    // when no pattern matches.
495                    failure_class: Some(classify(&e)),
496                    // RFC-029 P2b: extract the agent's exported state if
497                    // the error was wrapped by run_agent (AgentRunError).
498                    // This allows the RecoveryCoordinator to snapshot→
499                    // restore into a new model rather than restarting.
500                    restore_state: e
501                        .downcast_ref::<crate::resilience::AgentRunError>()
502                        .and_then(|err| err.restore_state.clone()),
503                })
504            }
505        }
506    }
507
508    async fn wait(&self, id: AgentId) -> Result<AgentStatus> {
509        let agents = self.agents.read();
510        match agents.get(&id) {
511            Some(info) => Ok(info.status),
512            None => anyhow::bail!("Agent {id} not found"),
513        }
514    }
515
516    async fn kill(&self, id: AgentId) -> Result<()> {
517        // Cancel and abort the running task, if any.
518        {
519            let mut handles = self.handles.write();
520            if let Some(agent_handle) = handles.remove(&id) {
521                agent_handle.cancelled.store(true, Ordering::Relaxed);
522                agent_handle.task.abort();
523                tracing::info!(agent_id = %id, "Agent task aborted");
524            }
525        }
526
527        {
528            let mut agents = self.agents.write();
529            if let Some(agent) = agents.get_mut(&id) {
530                agent.status = AgentStatus::Stopped;
531                agent.completed_at = Some(Utc::now());
532            } else {
533                anyhow::bail!("Agent {id} not found");
534            }
535        }
536
537        let _ = self
538            .event_bus
539            .publish(crate::event_bus::KernelEvent::AgentStopped { id, success: false });
540        self.update_agent_count();
541
542        // Persist to agent history log (async, non-blocking)
543        self.persist_agent(id).await;
544
545        tracing::info!(agent_id = %id, "Agent killed");
546        Ok(())
547    }
548
549    async fn list(&self) -> Result<Vec<AgentInfo>> {
550        let agents = self.agents.read();
551        Ok(agents.values().cloned().collect())
552    }
553}
554
555impl BasicSupervisor {
556    /// Persist a terminated agent to both filesystem JSON and SQLite.
557    /// Non-blocking: spawns a tokio task for the actual persistence.
558    async fn persist_agent(&self, id: AgentId) {
559        // Snapshot the agent info from the in-memory map
560        let info = {
561            let agents = self.agents.read();
562            agents.get(&id).cloned()
563        };
564
565        let Some(info) = info else { return };
566
567        // 1. Filesystem JSON (source of truth)
568        if let Some(ref store) = self.state_store {
569            let store = store.clone();
570            let info = info.clone();
571            let max_entries = self.agent_log_config.max_entries;
572            let ttl_hours = self.agent_log_config.ttl_hours;
573            let batch_size = self.agent_log_config.prune_batch_size;
574            tokio::spawn(async move {
575                let _ = store
576                    .save_json("agents", &id.to_string(), &info)
577                    .await
578                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to persist agent to filesystem"));
579
580                // Prune old records (async, best-effort)
581                if max_entries > 0 || ttl_hours > 0 {
582                    let _ = store
583                        .prune_agents_by_config(max_entries, ttl_hours, batch_size)
584                        .await
585                        .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent log"));
586                }
587            });
588        }
589
590        // 2. SQLite (query index)
591        #[cfg(feature = "sqlite-memory")]
592        if let Some(ref db) = self.agent_log_db {
593            let db = db.clone();
594            let info = info.clone();
595            let config = self.agent_log_config.clone();
596            tokio::spawn(async move {
597                let _ = db
598                    .upsert_agent(&info)
599                    .inspect_err(|e| tracing::warn!(agent_id = %id, error = %e, "Failed to upsert agent to SQLite"));
600
601                // Prune old records
602                let _ = db
603                    .prune(&config)
604                    .inspect_err(|e| tracing::warn!(error = %e, "Failed to prune agent SQLite"));
605            });
606        }
607    }
608}
609
610/// A no-op supervisor used during KernelBuilder::build() to break the
611/// KernelHandle → AgentRuntime → Supervisor → KernelHandle cycle.
612///
613/// AgentApi.supervisor is only used for list/kill operations, not during
614/// tool registration, so this placeholder is safe during build time.
615pub struct NoOpSupervisor;
616
617#[async_trait::async_trait]
618impl Supervisor for NoOpSupervisor {
619    async fn exec(&self, _id: AgentId) -> Result<()> {
620        Err(anyhow::anyhow!(
621            "NoOpSupervisor: exec not available during build"
622        ))
623    }
624    async fn fork_directive(&self, _directive: &Directive, _env: &ExecEnv) -> Result<AgentId> {
625        Err(anyhow::anyhow!(
626            "NoOpSupervisor: fork_directive not available during build"
627        ))
628    }
629    async fn run_with_directive(
630        &self,
631        _id: AgentId,
632        _directive: &Directive,
633        _env: &ExecEnv,
634    ) -> Result<ExecutionResult> {
635        Err(anyhow::anyhow!(
636            "NoOpSupervisor: run_with_directive not available during build"
637        ))
638    }
639    async fn wait(&self, _id: AgentId) -> Result<AgentStatus> {
640        Err(anyhow::anyhow!(
641            "NoOpSupervisor: wait not available during build"
642        ))
643    }
644    async fn kill(&self, _id: AgentId) -> Result<()> {
645        Err(anyhow::anyhow!(
646            "NoOpSupervisor: kill not available during build"
647        ))
648    }
649    async fn list(&self) -> Result<Vec<AgentInfo>> {
650        Ok(Vec::new())
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use crate::event_bus::EventBus;
658    use crate::types::AgentStatus;
659
660    // Note: MockProvider no longer needed — OxiosEngine handles provider resolution.
661    // The engine resolves models internally, so tests just use OxiosEngine::new().
662
663    /// Helper to create a real BasicSupervisor wired to a real EventBus.
664    async fn make_supervisor() -> BasicSupervisor {
665        let event_bus = EventBus::new(64);
666
667        // Build a mock KernelHandle with temp dirs.
668        let tmp = std::env::temp_dir().join(format!("oxios-test-{}", uuid::Uuid::new_v4()));
669        let _ = std::fs::create_dir_all(&tmp);
670
671        let state_store_2 =
672            Arc::new(crate::state_store::StateStore::new(tmp.join("state")).expect("state store"));
673        let state_store = state_store_2.clone();
674        let memory_manager = Arc::new({
675            let mut mm = crate::memory::MemoryManager::new(state_store.clone());
676            mm.set_git_layer(Arc::new(
677                crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
678            ));
679            mm
680        });
681
682        let kernel_handle = Arc::new(crate::KernelHandle::new(
683            crate::kernel_handle::StateApi::new(state_store),
684            crate::kernel_handle::AgentApi::new(
685                Arc::new(crate::supervisor::NoOpSupervisor),
686                Arc::new(crate::budget::BudgetManager::new()),
687                memory_manager.clone(),
688                Some(event_bus.clone()),
689            ),
690            crate::kernel_handle::SecurityApi::new(
691                Arc::new(parking_lot::Mutex::new(crate::auth::AuthManager::new())),
692                Arc::new(oxi_sdk::observability::AuditTrail::new(100)),
693                Arc::new(parking_lot::Mutex::new(
694                    crate::access_manager::AccessManager::new(),
695                )),
696                Arc::new(
697                    crate::state_store::StateStore::new(tmp.join("state2")).expect("state store 2"),
698                ),
699            ),
700            crate::kernel_handle::PersonaApi::new(Arc::new(crate::persona::PersonaManager::new())),
701            crate::kernel_handle::ExtensionApi::new(Arc::new(crate::skill::SkillManager::new(
702                tmp.join("skills"),
703                tmp.join("share/skills"),
704            ))),
705            crate::kernel_handle::McpApi::new(Arc::new(crate::mcp::McpBridge::new())),
706            crate::kernel_handle::InfraApi::new(
707                Arc::new(
708                    crate::git_layer::GitLayer::new(tmp.join("git"), false).expect("git layer"),
709                ),
710                Arc::new(crate::scheduler::AgentScheduler::new(4, 60, 300)),
711                Arc::new(crate::cron::CronScheduler::new(
712                    Arc::new(
713                        crate::state_store::StateStore::new(tmp.join("cron")).expect("cron state"),
714                    ),
715                    60,
716                )),
717                Arc::new(crate::resource_monitor::ResourceMonitor::new(60, 100)),
718                EventBus::new(64),
719                crate::config::OxiosConfig::default(),
720                std::time::Instant::now(),
721            ),
722            None,
723            crate::kernel_handle::ExecApi::new(
724                Arc::new(parking_lot::RwLock::new(
725                    crate::config::ExecConfig::default(),
726                )),
727                Arc::new(parking_lot::Mutex::new(
728                    crate::access_manager::AccessManager::new(),
729                )),
730            ),
731            crate::kernel_handle::A2aApi::new(Arc::new(crate::a2a::A2AProtocol::new(
732                EventBus::new(64),
733            ))),
734            crate::kernel_handle::EngineApi::new(
735                Arc::new(parking_lot::RwLock::new(
736                    crate::config::OxiosConfig::default(),
737                )),
738                tmp.join("config.toml"),
739                Arc::new(crate::kernel_handle::RoutingStats::new()),
740                Arc::new(crate::engine::EngineHandle::new(Arc::new(
741                    crate::OxiosEngine::new("anthropic/claude-sonnet-4-20250514"),
742                ))),
743            ),
744            Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
745            Arc::new(
746                crate::kernel_handle::KnowledgeLens::new(
747                    Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
748                    memory_manager.clone(),
749                )
750                .unwrap(),
751            ),
752            crate::kernel_handle::MarketplaceApi::new(
753                Arc::new(crate::skill::clawhub::ClawHubInstaller::new(
754                    tmp.join("skills"),
755                    tmp.join("state"),
756                    None,
757                )),
758                Arc::new(
759                    crate::skill::clawhub::ClawHubClient::new(None).expect("valid ClawHub client"),
760                ),
761                Arc::new(crate::skill::skills_sh::SkillsShInstaller::new(
762                    tmp.join("skills"),
763                    None,
764                    None,
765                )),
766                Arc::new(
767                    crate::skill::skills_sh::SkillsShClient::new(None, None)
768                        .expect("valid Skills.sh client"),
769                ),
770            ),
771            None, // calendar (not configured in test)
772            None, // email (not configured in test)
773        ));
774
775        let engine = crate::OxiosEngine::new("mock/model");
776        let engine_handle = Arc::new(crate::engine::EngineHandle::new(Arc::new(engine)));
777        let runtime = AgentRuntime::new(engine_handle, kernel_handle, None);
778        BasicSupervisor::new(event_bus, runtime)
779    }
780
781    /// Helper to create a minimal (Directive, ExecEnv) pair for testing.
782    fn make_directive(goal: &str) -> (Directive, ExecEnv) {
783        (Directive::from_message(goal), ExecEnv::default())
784    }
785
786    #[tokio::test]
787    async fn test_fork_creates_agent() {
788        let supervisor = make_supervisor().await;
789        let (directive, env) = make_directive("Test agent");
790
791        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
792
793        let agents = supervisor.list().await.unwrap();
794        assert_eq!(agents.len(), 1);
795        assert_eq!(agents[0].id, id);
796        assert_eq!(agents[0].name, "Test agent");
797        assert_eq!(agents[0].status, AgentStatus::Starting);
798        assert_eq!(agents[0].seed_id, None);
799    }
800
801    #[tokio::test]
802    async fn test_exec_updates_status_to_running() {
803        let supervisor = make_supervisor().await;
804        let (directive, env) = make_directive("Running agent");
805
806        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
807        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Starting);
808
809        supervisor.exec(id).await.unwrap();
810        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
811    }
812
813    #[tokio::test]
814    async fn test_kill_sets_stopped() {
815        let supervisor = make_supervisor().await;
816        let (directive, env) = make_directive("Doomed agent");
817
818        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
819        supervisor.exec(id).await.unwrap();
820        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
821
822        supervisor.kill(id).await.unwrap();
823        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Stopped);
824    }
825
826    #[tokio::test]
827    async fn test_kill_unknown_agent_returns_error() {
828        let supervisor = make_supervisor().await;
829        let unknown_id = uuid::Uuid::new_v4();
830
831        let result = supervisor.kill(unknown_id).await;
832        assert!(result.is_err());
833        assert!(result.unwrap_err().to_string().contains("not found"));
834    }
835
836    #[tokio::test]
837    async fn test_list_returns_all_agents() {
838        let supervisor = make_supervisor().await;
839
840        let (d1, e1) = make_directive("Agent 1");
841        let id1 = supervisor.fork_directive(&d1, &e1).await.unwrap();
842        let (d2, e2) = make_directive("Agent 2");
843        let id2 = supervisor.fork_directive(&d2, &e2).await.unwrap();
844        let (d3, e3) = make_directive("Agent 3");
845        let id3 = supervisor.fork_directive(&d3, &e3).await.unwrap();
846
847        let agents = supervisor.list().await.unwrap();
848        assert_eq!(agents.len(), 3);
849
850        let ids: std::collections::HashSet<AgentId> = agents.iter().map(|a| a.id).collect();
851        assert!(ids.contains(&id1));
852        assert!(ids.contains(&id2));
853        assert!(ids.contains(&id3));
854    }
855
856    #[tokio::test]
857    async fn test_exec_unknown_agent_returns_error() {
858        let supervisor = make_supervisor().await;
859        let unknown_id = uuid::Uuid::new_v4();
860
861        let result = supervisor.exec(unknown_id).await;
862        assert!(result.is_err());
863        assert!(result.unwrap_err().to_string().contains("not found"));
864    }
865
866    #[tokio::test]
867    async fn test_wait_unknown_agent_returns_error() {
868        let supervisor = make_supervisor().await;
869        let unknown_id = uuid::Uuid::new_v4();
870
871        let result = supervisor.wait(unknown_id).await;
872        assert!(result.is_err());
873        assert!(result.unwrap_err().to_string().contains("not found"));
874    }
875}