PicoBot/src/agent/gate.rs
xiaoxixi b558a0a99b feat: make sub-agent orchestration always-on, consolidate design docs
Remove the agent_orchestration enabled feature switch and root_delegates config; delegation edges now derive from the catalog's delegate_targets. Replace the four orchestration design/review docs with a single SUB_AGENT_DESIGN.md. Bump version to 1.13.0.
2026-08-13 18:07:32 +08:00

348 lines
12 KiB
Rust

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<OwnedSemaphorePermit>,
#[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<OwnedSemaphorePermit>,
#[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<String, Weak<Semaphore>>,
}
impl KeyedSemaphores {
fn new(permits: usize) -> Self {
Self {
permits,
map: DashMap::new(),
}
}
fn semaphore(self: &Arc<Self>, key: &str) -> Arc<Semaphore> {
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<Semaphore>,
run_session: Arc<KeyedSemaphores>,
provider_global: Arc<Semaphore>,
provider_session: Arc<KeyedSemaphores>,
tool_global: Arc<Semaphore>,
tool_session: Arc<KeyedSemaphores>,
}
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<Self> {
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<Self> {
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<Self>,
session_id: &str,
cancellation: &CancellationToken,
) -> Result<RunPermit, GateError> {
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<Self>,
session_id: Option<&str>,
cancellation: &CancellationToken,
) -> Result<StepPermit, GateError> {
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<Self>,
session_id: Option<&str>,
cancellation: &CancellationToken,
) -> Result<StepPermit, GateError> {
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<Semaphore>,
cancellation: &CancellationToken,
) -> Result<OwnedSemaphorePermit, GateError> {
tokio::select! {
biased;
_ = cancellation.cancelled() => Err(GateError::Cancelled),
result = semaphore.acquire_owned() => {
result.map_err(|_| GateError::Cancelled)
}
}
}
async fn acquire_keyed(
keyed: &Arc<KeyedSemaphores>,
key: &str,
cancellation: &CancellationToken,
) -> Result<OwnedSemaphorePermit, GateError> {
acquire_owned(keyed.semaphore(key), cancellation).await
}
/// Test helper: wait until a predicate holds or fail after the timeout.
#[cfg(test)]
async fn eventually<F: Fn() -> 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<ExecutionGate> {
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);
}
}