Skip to main content

oxios_kernel/
agent_lifecycle.rs

1//! Agent lifecycle management — fork, register, run, cleanup.
2//!
3//! Extracted from Orchestrator to reduce the god-object scope.
4//! Handles: fork agent → register A2A → check permissions →
5//! submit to scheduler → run → unregister → complete/fail.
6
7use anyhow::{Result, bail};
8use std::sync::Arc;
9
10use tokio::time::{Duration, timeout};
11
12use crate::a2a::{A2AProtocol, AgentCard};
13use crate::access_manager::{AccessManager, Role, Subject};
14use crate::event_bus::{EventBus, KernelEvent};
15use crate::metrics::get_metrics;
16use crate::scheduler::{AgentScheduler, Priority, ScheduledTask};
17use crate::supervisor::Supervisor;
18use crate::types::{AgentId, AgentStatus};
19use oxios_ouroboros::{Directive, ExecEnv, ExecutionResult};
20
21/// Manages the full lifecycle of a single agent from fork to cleanup.
22pub struct AgentLifecycleManager {
23    supervisor: Arc<dyn Supervisor>,
24    scheduler: Arc<AgentScheduler>,
25    access_manager: Arc<parking_lot::Mutex<AccessManager>>,
26    a2a: Arc<A2AProtocol>,
27    event_bus: EventBus,
28    /// Maximum execution time in seconds for agent tasks (0 = no limit).
29    max_execution_time_secs: std::sync::atomic::AtomicU64,
30    /// Default allowed tools from config.
31    allowed_tools: Vec<String>,
32    /// Whether agents get network access by default.
33    network_access: bool,
34    /// Workspace path for path sandbox.
35    workspace_path: String,
36}
37
38impl Clone for AgentLifecycleManager {
39    fn clone(&self) -> Self {
40        Self {
41            supervisor: self.supervisor.clone(),
42            scheduler: self.scheduler.clone(),
43            access_manager: self.access_manager.clone(),
44            a2a: self.a2a.clone(),
45            event_bus: self.event_bus.clone(),
46            max_execution_time_secs: std::sync::atomic::AtomicU64::new(
47                self.max_execution_time_secs
48                    .load(std::sync::atomic::Ordering::Relaxed),
49            ),
50            allowed_tools: self.allowed_tools.clone(),
51            network_access: self.network_access,
52            workspace_path: self.workspace_path.clone(),
53        }
54    }
55}
56
57impl AgentLifecycleManager {
58    /// Create a new lifecycle manager.
59    #[allow(clippy::too_many_arguments)]
60    pub fn new(
61        supervisor: Arc<dyn Supervisor>,
62        scheduler: Arc<AgentScheduler>,
63        access_manager: Arc<parking_lot::Mutex<AccessManager>>,
64        a2a: Arc<A2AProtocol>,
65        event_bus: EventBus,
66        max_execution_time_secs: u64,
67        allowed_tools: Vec<String>,
68        network_access: bool,
69        workspace_path: String,
70    ) -> Self {
71        Self {
72            supervisor,
73            scheduler,
74            access_manager,
75            a2a,
76            event_bus,
77            max_execution_time_secs: std::sync::atomic::AtomicU64::new(max_execution_time_secs),
78            allowed_tools,
79            network_access,
80            workspace_path,
81        }
82    }
83
84    /// Hot-reload max execution time without restart.
85    pub fn set_max_execution_time(&self, secs: u64) {
86        self.max_execution_time_secs
87            .store(secs, std::sync::atomic::Ordering::Relaxed);
88        tracing::info!(
89            max_execution_time_secs = secs,
90            "Lifecycle config hot-reloaded"
91        );
92    }
93
94    /// Fork an agent, register it in A2A and access control, submit to
95    /// scheduler, run the directive + exec env, then clean up (RFC-027).
96    pub async fn execute_directive(
97        &self,
98        directive: &Directive,
99        env: &ExecEnv,
100        priority: Priority,
101    ) -> Result<ExecutionResult> {
102        // 1. Fork
103        let agent_id = self.supervisor.fork_directive(directive, env).await?;
104        let agent_name = format!("agent-{agent_id}");
105        tracing::info!(agent_id = %agent_id, "Agent forked from directive");
106
107        // 2. Register A2A card
108        let card = self.build_agent_card_directive(agent_id, &agent_name, directive);
109        if let Err(e) = self.a2a.registry().register_agent(card).await {
110            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to register A2A card");
111        }
112
113        // 2b. Deliver any pending A2A messages to this agent
114        if let Err(e) = self.a2a.deliver_pending_messages(agent_id).await {
115            tracing::debug!(agent_id = %agent_id, error = %e, "No pending A2A messages");
116        }
117
118        // 3. Ensure access permissions
119        self.ensure_permissions(&agent_name);
120
121        // 4. Submit and start task
122        get_metrics().agents_forked.inc();
123        let task = ScheduledTask::for_agent(
124            agent_id,
125            format!("Execute directive '{}'", directive.goal),
126            priority,
127        );
128        let task_id = self.scheduler.submit(task)?;
129        self.scheduler.start_task(task_id)?;
130
131        // 5. Run — always cleanup even on failure
132        let max_secs = self
133            .max_execution_time_secs
134            .load(std::sync::atomic::Ordering::Relaxed);
135        let result = if max_secs > 0 {
136            let exec_timeout = Duration::from_secs(max_secs);
137            match timeout(
138                exec_timeout,
139                self.supervisor.run_with_directive(agent_id, directive, env),
140            )
141            .await
142            {
143                Ok(Ok(r)) => r,
144                Ok(Err(e)) => {
145                    tracing::warn!(agent_id = %agent_id, error = %e, "Agent execution failed, cleaning up");
146                    self.cleanup_on_failure(agent_id, task_id).await;
147                    return Err(e);
148                }
149                Err(_) => {
150                    let secs = exec_timeout.as_secs();
151                    tracing::warn!(
152                        agent_id = %agent_id,
153                        secs,
154                        "Agent execution timed out after {}s",
155                        secs
156                    );
157                    // Abort the detached execution body. Previously the
158                    // timeout only dropped the awaiting future while the
159                    // spawned task kept running — leaking tokens/resources.
160                    let _ = self.supervisor.kill(agent_id).await;
161                    self.cleanup_on_failure(agent_id, task_id).await;
162                    bail!("Agent execution timed out after {secs} seconds");
163                }
164            }
165        } else {
166            match self
167                .supervisor
168                .run_with_directive(agent_id, directive, env)
169                .await
170            {
171                Ok(r) => r,
172                Err(e) => {
173                    tracing::warn!(agent_id = %agent_id, error = %e, "Agent execution failed, cleaning up");
174                    self.cleanup_on_failure(agent_id, task_id).await;
175                    return Err(e);
176                }
177            }
178        };
179
180        // 6. Cleanup on success
181        self.cleanup(agent_id, task_id, &result).await;
182
183        Ok(result)
184    }
185
186    /// Execute a directive with feedback from a previous failed attempt (RFC-027).
187    ///
188    /// Injects the previous result's output and the review gaps into the
189    /// directive's constraints so the agent sees what went wrong.
190    pub async fn execute_with_feedback(
191        &self,
192        directive: &Directive,
193        env: &ExecEnv,
194        prev_result: &ExecutionResult,
195        gaps: &[String],
196        priority: Priority,
197    ) -> Result<ExecutionResult> {
198        // Augment the directive with feedback from the previous attempt.
199        let mut augmented = directive.clone();
200        let feedback = format!(
201            "## Previous attempt failed\n{}\n\n## Unmet criteria\n{}\n\n\
202             Review the above output and fix the unmet criteria.",
203            prev_result.output,
204            gaps.iter()
205                .enumerate()
206                .map(|(i, g)| format!("{}. {g}", i + 1))
207                .collect::<Vec<_>>()
208                .join("\n")
209        );
210        augmented.constraints.push(feedback);
211
212        self.execute_directive(&augmented, env, priority).await
213    }
214
215    /// Kill an agent and clean up all registered state.
216    pub async fn terminate(&self, agent_id: AgentId) -> Result<()> {
217        if let Err(e) = self.supervisor.kill(agent_id).await {
218            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to kill agent");
219        }
220        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
221            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
222        }
223        let _ = self.event_bus.publish(KernelEvent::AgentStopped {
224            id: agent_id,
225            success: false,
226        });
227        Ok(())
228    }
229
230    /// Build an A2A agent card from a Directive (RFC-027).
231    ///
232    /// Reads the goal from a Directive and advertises `execute-directive`
233    /// so A2A consumers know the agent follows the directive path.
234    fn build_agent_card_directive(
235        &self,
236        agent_id: AgentId,
237        agent_name: &str,
238        directive: &Directive,
239    ) -> AgentCard {
240        let goal_lower = directive.goal.to_lowercase();
241
242        let mut card = AgentCard::new(
243            agent_id,
244            agent_name,
245            format!("Agent executing directive: {}", directive.goal),
246        )
247        .with_capability("execute-directive")
248        .with_status(AgentStatus::Starting);
249
250        // Infer capabilities from goal.
251        if goal_lower.contains("review") || goal_lower.contains("code") {
252            card = card.with_capability("code-review");
253        }
254        if goal_lower.contains("test") {
255            card = card.with_capability("testing");
256        }
257        if goal_lower.contains("refactor") || goal_lower.contains("improve") {
258            card = card.with_capability("refactoring");
259        }
260        if goal_lower.contains("write")
261            || goal_lower.contains("create")
262            || goal_lower.contains("implement")
263        {
264            card = card.with_capability("code-generation");
265        }
266        if goal_lower.contains("debug") || goal_lower.contains("fix") {
267            card = card.with_capability("debugging");
268        }
269
270        card
271    }
272
273    /// Ensure default tool permissions exist for an agent.
274    ///
275    /// Applies config.toml `[security]` settings:
276    /// - `allowed_tools` → agent's tool set
277    /// - `network_access` → network permission
278    /// - workspace path → path sandbox
279    /// - RBAC `Superuser` role → allows all tools and paths
280    fn ensure_permissions(&self, agent_name: &str) {
281        let mut access = self.access_manager.lock();
282        let perms = access.get_or_create_permissions(agent_name);
283
284        // Grant all tools from config
285        for tool in &self.allowed_tools {
286            if !perms.allowed_tools.contains(tool.as_str()) {
287                perms.allow_tool(tool);
288            }
289        }
290
291        // Add workspace path to allowed paths
292        let ws_pattern = format!("{}/**", self.workspace_path.trim_end_matches('/'));
293        if !perms.allowed_paths.iter().any(|p| p == &ws_pattern) {
294            perms.allow_path(&ws_pattern);
295        }
296        // Also allow /tmp for agent temp files
297        if !perms.allowed_paths.iter().any(|p| p == "/tmp/**") {
298            perms.allow_path("/tmp/**");
299        }
300
301        // Apply network access from config
302        if self.network_access {
303            perms.enable_network();
304        }
305
306        // Assign Superuser RBAC role so AccessGate passes
307        // (config.toml already defines which tools are allowed)
308        let subject = Subject::Agent(
309            agent_name
310                .strip_prefix("agent-")
311                .and_then(|s| s.parse().ok())
312                .unwrap_or_default(),
313        );
314        access
315            .rbac_manager_mut()
316            .assign_role(subject, Role::Superuser);
317    }
318
319    /// Unregister A2A, complete/fail scheduler task.
320    async fn cleanup(&self, agent_id: AgentId, task_id: uuid::Uuid, result: &ExecutionResult) {
321        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
322            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
323        }
324        if result.success {
325            let _ = self.scheduler.complete_task(task_id);
326        } else {
327            let _ = self.scheduler.fail_task(task_id, &result.output);
328        }
329    }
330
331    /// Reap finished zombie tasks and log the cleanup.
332    pub fn reap_zombies(&self) -> Vec<uuid::Uuid> {
333        let reaped = self.scheduler.reap_zombies();
334        if !reaped.is_empty() {
335            tracing::warn!(count = reaped.len(), "Zombie tasks reaped");
336            let mut access = self.access_manager.lock();
337            for task_id in &reaped {
338                access.log_access("scheduler", "zombie_reap", &task_id.to_string(), true, None);
339            }
340        }
341        reaped
342    }
343
344    /// Cleanup when agent execution fails (no ExecutionResult available).
345    async fn cleanup_on_failure(&self, agent_id: AgentId, task_id: uuid::Uuid) {
346        if let Err(e) = self.a2a.registry().unregister_agent(agent_id).await {
347            tracing::warn!(agent_id = %agent_id, error = %e, "Failed to unregister A2A card");
348        }
349        let _ = self.scheduler.fail_task(task_id, "execution failed");
350    }
351}