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::cron::CronScheduler::new(
711                    Arc::new(
712                        crate::state_store::StateStore::new(tmp.join("cron")).expect("cron state"),
713                    ),
714                    60,
715                )),
716                Arc::new(crate::resource_monitor::ResourceMonitor::new(60, 100)),
717                EventBus::new(64),
718                crate::config::OxiosConfig::default(),
719                std::time::Instant::now(),
720            ),
721            None,
722            crate::kernel_handle::ExecApi::new(
723                Arc::new(parking_lot::RwLock::new(
724                    crate::config::ExecConfig::default(),
725                )),
726                Arc::new(parking_lot::Mutex::new(
727                    crate::access_manager::AccessManager::new(),
728                )),
729            ),
730            crate::kernel_handle::A2aApi::new(Arc::new(crate::a2a::A2AProtocol::new(
731                EventBus::new(64),
732            ))),
733            crate::kernel_handle::EngineApi::new(
734                Arc::new(parking_lot::RwLock::new(
735                    crate::config::OxiosConfig::default(),
736                )),
737                tmp.join("config.toml"),
738                Arc::new(crate::kernel_handle::RoutingStats::new()),
739                Arc::new(crate::engine::EngineHandle::new(Arc::new(
740                    crate::OxiosEngine::new("anthropic/claude-sonnet-4-20250514"),
741                ))),
742            ),
743            Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
744            Arc::new(
745                crate::kernel_handle::KnowledgeLens::new(
746                    Arc::new(oxios_markdown::KnowledgeBase::new(tmp.join("knowledge")).unwrap()),
747                    memory_manager.clone(),
748                )
749                .unwrap(),
750            ),
751            crate::kernel_handle::MarketplaceApi::new(
752                Arc::new(crate::skill::clawhub::ClawHubInstaller::new(
753                    tmp.join("skills"),
754                    tmp.join("state"),
755                    None,
756                )),
757                Arc::new(
758                    crate::skill::clawhub::ClawHubClient::new(None).expect("valid ClawHub client"),
759                ),
760                Arc::new(crate::skill::skills_sh::SkillsShInstaller::new(
761                    tmp.join("skills"),
762                    None,
763                    None,
764                )),
765                Arc::new(
766                    crate::skill::skills_sh::SkillsShClient::new(None, None)
767                        .expect("valid Skills.sh client"),
768                ),
769            ),
770            None, // calendar (not configured in test)
771            None, // email (not configured in test)
772        ));
773
774        let engine = crate::OxiosEngine::new("mock/model");
775        let engine_handle = Arc::new(crate::engine::EngineHandle::new(Arc::new(engine)));
776        let runtime = AgentRuntime::new(engine_handle, kernel_handle, None);
777        BasicSupervisor::new(event_bus, runtime)
778    }
779
780    /// Helper to create a minimal (Directive, ExecEnv) pair for testing.
781    fn make_directive(goal: &str) -> (Directive, ExecEnv) {
782        (Directive::from_message(goal), ExecEnv::default())
783    }
784
785    #[tokio::test]
786    async fn test_fork_creates_agent() {
787        let supervisor = make_supervisor().await;
788        let (directive, env) = make_directive("Test agent");
789
790        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
791
792        let agents = supervisor.list().await.unwrap();
793        assert_eq!(agents.len(), 1);
794        assert_eq!(agents[0].id, id);
795        assert_eq!(agents[0].name, "Test agent");
796        assert_eq!(agents[0].status, AgentStatus::Starting);
797        assert_eq!(agents[0].seed_id, None);
798    }
799
800    #[tokio::test]
801    async fn test_exec_updates_status_to_running() {
802        let supervisor = make_supervisor().await;
803        let (directive, env) = make_directive("Running agent");
804
805        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
806        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Starting);
807
808        supervisor.exec(id).await.unwrap();
809        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
810    }
811
812    #[tokio::test]
813    async fn test_kill_sets_stopped() {
814        let supervisor = make_supervisor().await;
815        let (directive, env) = make_directive("Doomed agent");
816
817        let id = supervisor.fork_directive(&directive, &env).await.unwrap();
818        supervisor.exec(id).await.unwrap();
819        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Running);
820
821        supervisor.kill(id).await.unwrap();
822        assert_eq!(supervisor.wait(id).await.unwrap(), AgentStatus::Stopped);
823    }
824
825    #[tokio::test]
826    async fn test_kill_unknown_agent_returns_error() {
827        let supervisor = make_supervisor().await;
828        let unknown_id = uuid::Uuid::new_v4();
829
830        let result = supervisor.kill(unknown_id).await;
831        assert!(result.is_err());
832        assert!(result.unwrap_err().to_string().contains("not found"));
833    }
834
835    #[tokio::test]
836    async fn test_list_returns_all_agents() {
837        let supervisor = make_supervisor().await;
838
839        let (d1, e1) = make_directive("Agent 1");
840        let id1 = supervisor.fork_directive(&d1, &e1).await.unwrap();
841        let (d2, e2) = make_directive("Agent 2");
842        let id2 = supervisor.fork_directive(&d2, &e2).await.unwrap();
843        let (d3, e3) = make_directive("Agent 3");
844        let id3 = supervisor.fork_directive(&d3, &e3).await.unwrap();
845
846        let agents = supervisor.list().await.unwrap();
847        assert_eq!(agents.len(), 3);
848
849        let ids: std::collections::HashSet<AgentId> = agents.iter().map(|a| a.id).collect();
850        assert!(ids.contains(&id1));
851        assert!(ids.contains(&id2));
852        assert!(ids.contains(&id3));
853    }
854
855    #[tokio::test]
856    async fn test_exec_unknown_agent_returns_error() {
857        let supervisor = make_supervisor().await;
858        let unknown_id = uuid::Uuid::new_v4();
859
860        let result = supervisor.exec(unknown_id).await;
861        assert!(result.is_err());
862        assert!(result.unwrap_err().to_string().contains("not found"));
863    }
864
865    #[tokio::test]
866    async fn test_wait_unknown_agent_returns_error() {
867        let supervisor = make_supervisor().await;
868        let unknown_id = uuid::Uuid::new_v4();
869
870        let result = supervisor.wait(unknown_id).await;
871        assert!(result.is_err());
872        assert!(result.unwrap_err().to_string().contains("not found"));
873    }
874}