use std::sync::{Arc, Weak}; use dashmap::DashMap; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio_util::sync::CancellationToken; /// Step permits are held while a provider request or tool invocation is in /// flight and released as soon as the step finishes. Session permits are /// released before global permits (reverse acquisition order). The fields /// are never read; ownership alone keeps the quotas reserved (RAII). #[derive(Debug)] pub struct StepPermit { #[allow(dead_code)] session: Option, #[allow(dead_code)] global: OwnedSemaphorePermit, } /// Run quota permits cover a whole Agent Run from admission until its /// terminal commit. Foreground delegation does not take run permits; the /// quota gates background admission so a waiting parent can never deadlock /// nested foreground children. #[derive(Debug)] pub struct RunPermit { #[allow(dead_code)] session: Option, #[allow(dead_code)] global: OwnedSemaphorePermit, } #[derive(Debug, thiserror::Error)] pub enum GateError { #[error("execution gate wait cancelled")] Cancelled, } #[derive(Default)] struct KeyedSemaphores { permits: usize, map: DashMap>, } impl KeyedSemaphores { fn new(permits: usize) -> Self { Self { permits, map: DashMap::new(), } } fn semaphore(self: &Arc, key: &str) -> Arc { if let Some(existing) = self.map.get(key) && let Some(semaphore) = existing.upgrade() { return semaphore; } let semaphore = Arc::new(Semaphore::new(self.permits)); self.map.insert(key.to_string(), Arc::downgrade(&semaphore)); // Drop registry entries whose owners are gone so long-lived gateways // do not accumulate one semaphore per historical session. self.map.retain(|_, weak| weak.strong_count() > 0); semaphore } } /// Concurrency gates shared by one runtime generation. Run quota and /// provider/tool step permits have independent lifecycles; acquisition order /// is always global -> session and release order is reversed. pub struct ExecutionGate { max_concurrent_runs: usize, run_global: Arc, run_session: Arc, provider_global: Arc, provider_session: Arc, tool_global: Arc, tool_session: Arc, } impl std::fmt::Debug for ExecutionGate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExecutionGate") .field("run_available", &self.run_global.available_permits()) .field( "provider_available", &self.provider_global.available_permits(), ) .field("tool_available", &self.tool_global.available_permits()) .finish() } } impl ExecutionGate { pub fn new(config: &crate::config::AgentOrchestrationConfig) -> Arc { Arc::new(Self { max_concurrent_runs: config.max_concurrent_runs, run_global: Arc::new(Semaphore::new(config.max_concurrent_runs)), run_session: Arc::new(KeyedSemaphores::new(config.max_concurrent_runs_per_session)), provider_global: Arc::new(Semaphore::new(config.max_concurrent_provider_steps)), provider_session: Arc::new(KeyedSemaphores::new( config.max_concurrent_provider_steps_per_session, )), tool_global: Arc::new(Semaphore::new(config.max_concurrent_tool_steps)), tool_session: Arc::new(KeyedSemaphores::new( config.max_concurrent_tool_steps_per_session, )), }) } /// Unlimited gate used when orchestration is disabled; root Turns still /// route through it so the code path stays uniform. pub fn unbounded() -> Arc { Arc::new(Self { max_concurrent_runs: usize::MAX, run_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)), run_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)), provider_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)), provider_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)), tool_global: Arc::new(Semaphore::new(Semaphore::MAX_PERMITS)), tool_session: Arc::new(KeyedSemaphores::new(Semaphore::MAX_PERMITS)), }) } /// Global run quota ceiling; background batches larger than this are /// rejected up front instead of queueing indefinitely. pub fn max_concurrent_runs(&self) -> usize { self.max_concurrent_runs } pub async fn acquire_run( self: &Arc, session_id: &str, cancellation: &CancellationToken, ) -> Result { let global = acquire_owned(self.run_global.clone(), cancellation).await?; let session = acquire_keyed(&self.run_session, session_id, cancellation).await?; Ok(RunPermit { session: Some(session), global, }) } pub async fn acquire_provider( self: &Arc, session_id: Option<&str>, cancellation: &CancellationToken, ) -> Result { let global = acquire_owned(self.provider_global.clone(), cancellation).await?; let session = match session_id { Some(session_id) => { Some(acquire_keyed(&self.provider_session, session_id, cancellation).await?) } None => None, }; Ok(StepPermit { session, global }) } pub async fn acquire_tool( self: &Arc, session_id: Option<&str>, cancellation: &CancellationToken, ) -> Result { let global = acquire_owned(self.tool_global.clone(), cancellation).await?; let session = match session_id { Some(session_id) => { Some(acquire_keyed(&self.tool_session, session_id, cancellation).await?) } None => None, }; Ok(StepPermit { session, global }) } pub fn available_provider_permits(&self) -> usize { self.provider_global.available_permits() } pub fn available_tool_permits(&self) -> usize { self.tool_global.available_permits() } } async fn acquire_owned( semaphore: Arc, cancellation: &CancellationToken, ) -> Result { tokio::select! { biased; _ = cancellation.cancelled() => Err(GateError::Cancelled), result = semaphore.acquire_owned() => { result.map_err(|_| GateError::Cancelled) } } } async fn acquire_keyed( keyed: &Arc, key: &str, cancellation: &CancellationToken, ) -> Result { acquire_owned(keyed.semaphore(key), cancellation).await } /// Test helper: wait until a predicate holds or fail after the timeout. #[cfg(test)] async fn eventually bool>(predicate: F) { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); while std::time::Instant::now() < deadline { if predicate() { return; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; } panic!("condition did not hold within timeout"); } #[cfg(test)] mod tests { use super::*; use std::time::Duration; use crate::config::AgentOrchestrationConfig; /// Global caps stay larger than session caps so a session-level waiter /// can never exhaust the global permits while blocked (acquisition order /// is global -> session by design). fn gate(provider_session: usize, tool_session: usize) -> Arc { let config = AgentOrchestrationConfig { max_concurrent_runs: 8, max_concurrent_runs_per_session: 1, max_concurrent_provider_steps: 8, max_concurrent_provider_steps_per_session: provider_session, max_concurrent_tool_steps: 8, max_concurrent_tool_steps_per_session: tool_session, ..Default::default() }; ExecutionGate::new(&config) } #[tokio::test] async fn provider_permits_are_released_after_drop() { let gate = gate(1, 1); let token = CancellationToken::new(); let permit = gate.acquire_provider(None, &token).await.unwrap(); assert_eq!(gate.available_provider_permits(), 7); drop(permit); assert_eq!(gate.available_provider_permits(), 8); } #[tokio::test] async fn session_quota_blocks_second_session_step_until_release() { let gate = gate(1, 8); let token = CancellationToken::new(); let first = gate .acquire_provider(Some("cli:a:d1"), &token) .await .unwrap(); // Global capacity remains, but the same session is capped at one. let blocked = tokio::spawn({ let gate = gate.clone(); let token = token.clone(); async move { gate.acquire_provider(Some("cli:a:d1"), &token).await } }); tokio::time::sleep(Duration::from_millis(50)).await; assert!(!blocked.is_finished()); // A different session is unaffected. let other = gate .acquire_provider(Some("cli:b:d2"), &token) .await .unwrap(); drop(other); drop(first); let second = blocked.await.unwrap().unwrap(); drop(second); assert_eq!(gate.available_provider_permits(), 8); } #[tokio::test] async fn cancellation_aborts_permit_wait() { let gate = gate(8, 1); let token = CancellationToken::new(); let held = gate.acquire_tool(Some("cli:a:d1"), &token).await.unwrap(); let waiter_token = CancellationToken::new(); let waiter = tokio::spawn({ let gate = gate.clone(); let waiter_token = waiter_token.clone(); async move { gate.acquire_tool(Some("cli:a:d1"), &waiter_token).await } }); tokio::time::sleep(Duration::from_millis(50)).await; waiter_token.cancel(); let error = waiter.await.unwrap().unwrap_err(); assert!(matches!(error, GateError::Cancelled)); drop(held); assert_eq!(gate.available_tool_permits(), 8); } #[tokio::test] async fn run_quota_is_scoped_per_session() { let gate = gate(8, 8); let token = CancellationToken::new(); let held = gate.acquire_run("cli:a:d1", &token).await.unwrap(); let blocked = tokio::spawn({ let gate = gate.clone(); let token = token.clone(); async move { gate.acquire_run("cli:a:d1", &token).await } }); tokio::time::sleep(Duration::from_millis(50)).await; assert!(!blocked.is_finished()); let other = gate.acquire_run("cli:b:d2", &token).await.unwrap(); drop(other); drop(held); blocked.await.unwrap().unwrap(); } #[tokio::test] async fn keyed_semaphores_are_reclaimed_when_idle() { let keyed = Arc::new(KeyedSemaphores::new(1)); let semaphore = keyed.semaphore("cli:a:d1"); assert_eq!(keyed.map.len(), 1); drop(semaphore); keyed.semaphore("cli:b:d2"); assert_eq!(keyed.map.len(), 1, "idle session entry must be reclaimed"); } #[tokio::test] async fn waiting_parent_holds_no_step_permits() { // With a session cap of 1, a parent that merely waits for its child // must leave the session's single provider permit free; otherwise // nested foreground delegation would deadlock. let gate = gate(1, 1); let token = CancellationToken::new(); let parent_step = gate .acquire_provider(Some("cli:a:d1"), &token) .await .unwrap(); drop(parent_step); // Parent is now in the waiting_children state: no permits held. eventually(|| gate.available_provider_permits() == 8).await; let child = gate .acquire_provider(Some("cli:a:d1"), &token) .await .unwrap(); drop(child); assert_eq!(gate.available_provider_permits(), 8); } }