//! Agent pool — manages the set of active agents across all pipeline stages. pub(crate) mod auto_assign; mod pipeline; mod process; mod query; mod start; mod stop; mod types; mod wait; mod worktree; #[cfg(test)] mod test_helpers; use crate::io::watcher::WatcherEvent; use portable_pty::ChildKiller; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; // Bring pool-internal types into pool's namespace so that sub-modules // (auto_assign, pipeline, etc.) can access them via `use super::...`. use types::{StoryAgent, composite_key}; use worktree::find_active_story_stage; /// Manages concurrent story agents, each in its own worktree. pub struct AgentPool { agents: Arc>>, port: u16, /// Registry of active PTY child process killers, keyed by "{story_id}:{agent_name}". /// Used to terminate child processes on server shutdown or agent stop, preventing /// orphaned Claude Code processes from running after the server exits. child_killers: Arc>>>, /// Broadcast channel for notifying WebSocket clients of agent state changes. /// When an agent transitions state (Pending, Running, Completed, Failed, Stopped), /// an `AgentStateChanged` event is emitted so the frontend can refresh the /// pipeline board without waiting for a filesystem event. watcher_tx: broadcast::Sender, /// Tracks background merge jobs started by `merge_agent_work`, keyed by story_id. /// The MCP tool returns immediately and the mergemaster agent polls /// `get_merge_status` until the job reaches a terminal state. merge_jobs: Arc>>, } impl AgentPool { pub fn new(port: u16, watcher_tx: broadcast::Sender) -> Self { let pool = Self { agents: Arc::new(Mutex::new(HashMap::new())), port, child_killers: Arc::new(Mutex::new(HashMap::new())), watcher_tx: watcher_tx.clone(), merge_jobs: Arc::new(Mutex::new(HashMap::new())), }; // Spawn a background task (only when inside a tokio runtime) that // listens for RateLimitWarning and HardBlock events and updates the // listens for RateLimitWarning and RateLimitHardBlock events and updates the // throttled flag on the relevant agent so status dots stay current. if tokio::runtime::Handle::try_current().is_ok() { let agents_clone = Arc::clone(&pool.agents); let watcher_tx_clone = watcher_tx.clone(); let mut rx = watcher_tx.subscribe(); tokio::spawn(async move { loop { let event = match rx.recv().await { Ok(e) => e, Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Lagged(_)) => continue, }; let (story_id, agent_name) = match &event { WatcherEvent::RateLimitWarning { story_id, agent_name, } | WatcherEvent::RateLimitHardBlock { story_id, agent_name, .. } => (story_id.clone(), agent_name.clone()), _ => continue, }; let key = composite_key(&story_id, &agent_name); if let Ok(mut agents) = agents_clone.lock() && let Some(agent) = agents.get_mut(&key) { agent.throttled = true; } let _ = watcher_tx_clone.send(WatcherEvent::AgentStateChanged); } }); } pool } pub fn port(&self) -> u16 { self.port } /// Create a pool with a dummy watcher channel for unit tests. #[cfg(test)] pub fn new_test(port: u16) -> Self { let (watcher_tx, _) = broadcast::channel(16); Self::new(port, watcher_tx) } /// Notify WebSocket clients that agent state has changed, so the pipeline /// board and agent panel can refresh. fn notify_agent_state_changed(watcher_tx: &broadcast::Sender) { let _ = watcher_tx.send(WatcherEvent::AgentStateChanged); } }