PicoBot/src/agent/coordinator.rs
xiaoxixi d9ad58b84b feat(scheduler): unify scheduled task execution and delivery
Replace the dual task/monitor model, NO_REPLY string protocol, and Agent
self-delivery with a single Scheduled Run path: claim-time JobRun snapshots,
isolated Root/named Agent execution, exactly-once complete_scheduled_run
termination, and Scheduler-owned policy delivery through a persistent outbox.

- SQLite v11: drop job_kind/model/delete_after_run, add job_runs with
  status/outcome joint constraints and delivery lease columns; one-shot
  BEGIN IMMEDIATE migration with atomic rollback.
- Non-blocking JoinSet event loop with bounded run/delivery concurrency;
  terminal commit before any channel I/O; recover unfinished runs as unknown.
- ExecutionOrigin::Scheduled propagates to descendants, completion sink is
  top-level only, background delegation downgrades to foreground.
- Typed delivery receipts, fixed target_session_id, idempotent
  scheduled:<job_run_id> history insert.
- New cron_runs read-only tool; cron_add/update drop kind/model; WebUI and
  Health consume the same JobRun projection.
- Bump version to 1.22.0.
2026-08-21 14:59:02 +08:00

2109 lines
80 KiB
Rust

use std::sync::Arc;
use dashmap::DashMap;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::agent::SubAgentManager;
use crate::agent::inbox::AgentInboxNotifier;
use crate::agent::projection::{AgentProjection, AgentProjectionHub};
use crate::agent::run::AgentExecutionContext;
use crate::agent::sub_agent::{
ExecutionMode, ResolvedAgentRun, SubAgentConfig, SubAgentError, SubAgentResult, TaskStatus,
};
use crate::storage::Storage;
use crate::storage::agent_inbox::AgentEventType;
use crate::storage::agent_run::{
AcceptAgentRequest, AcceptedAgentRuns, AgentRunMode, AgentRunRecord, AgentRunStatus,
AgentTerminalOutcome, NewAgentRun,
};
use crate::tools::ToolExecutionContext;
use crate::tools::emit_signal::{SignalAccepted, SignalAcceptedStatus, SignalInput};
/// Durable Agent orchestration. Every named run is persisted in
/// `agent_runs` before execution, transitions are execution-ID conditional,
/// and the terminal commit is the single writer of final state. Background
/// runs reserve an inbox completion slot at admission; their completion event
/// is materialized by the terminal commit and delivered through the Session
/// continuation lane instead of a direct channel notification.
/// Admission result for a background batch: the run ids actually spawned.
#[derive(Debug, Clone)]
pub struct BackgroundAdmission {
pub run_ids: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct ScheduledAgentExecution {
pub agent_run_id: String,
pub status: crate::storage::ScheduledRunStatus,
pub outcome: Option<crate::tools::ScheduledOutcome>,
pub error: Option<String>,
pub agent_terminal: AgentTerminalOutcome,
pub runtime_generation: i64,
}
pub struct AgentCoordinator {
storage: Arc<Storage>,
manager: Arc<SubAgentManager>,
work_manager: Option<Arc<crate::work::WorkManager>>,
notifier: Arc<AgentInboxNotifier>,
projection: Arc<AgentProjectionHub>,
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
admission: crate::gateway::reload::RuntimeAdmission,
task_supervisor: crate::task_supervisor::TaskSupervisor,
runtime_generation: i64,
max_pending_inbox_events_per_session: i64,
max_inbox_delivery_attempts: i64,
active_tokens: DashMap<String, CancellationToken>,
}
#[derive(Debug, thiserror::Error)]
pub enum CoordinatorError {
#[error("agent orchestration rejected the request: {0}")]
Rejected(String),
#[error("agent run storage error: {0}")]
Storage(#[from] crate::storage::StorageError),
#[error(transparent)]
SubAgent(#[from] SubAgentError),
}
impl AgentCoordinator {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
storage: Arc<Storage>,
manager: Arc<SubAgentManager>,
work_manager: Arc<crate::work::WorkManager>,
notifier: Arc<AgentInboxNotifier>,
projection: Arc<AgentProjectionHub>,
execution_gate: Arc<crate::agent::gate::ExecutionGate>,
admission: crate::gateway::reload::RuntimeAdmission,
task_supervisor: crate::task_supervisor::TaskSupervisor,
runtime_generation: u64,
orchestration: &crate::config::AgentOrchestrationConfig,
) -> Arc<Self> {
Arc::new(Self {
storage,
manager,
work_manager: Some(work_manager),
notifier,
projection,
execution_gate,
admission,
task_supervisor,
runtime_generation: runtime_generation as i64,
max_pending_inbox_events_per_session: orchestration.max_pending_inbox_events_per_session
as i64,
max_inbox_delivery_attempts: i64::from(orchestration.max_inbox_delivery_attempts),
active_tokens: DashMap::new(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_scheduled(
self: &Arc<Self>,
job_run_id: i64,
lease_owner: &str,
job_id: &str,
job_name: &str,
agent_id: Option<&str>,
prompt: &str,
timeout_secs: u64,
) -> Result<ScheduledAgentExecution, CoordinatorError> {
let run_id = Uuid::new_v4().to_string();
let root_session_id = format!("scheduled-run:{job_run_id}");
let sink = Arc::new(crate::tools::ScheduledCompletionSink::default());
let caller = ToolExecutionContext::for_session(root_session_id.clone())
.with_turn_id(format!("scheduled:{job_run_id}"))
.with_execution_origin(crate::tools::ExecutionOrigin::Scheduled { job_run_id });
let contract = format!(
"## Unattended Scheduled Run\n\nYou are executing scheduled task “{job_name}” ({job_id}). The user will not see ordinary final text. After completing all necessary work, you must call complete_scheduled_run exactly once. Use ok only when the task completed and found nothing requiring attention; use alert for actionable findings; use failed when the task did not complete reliably; use refused for a permission or safety refusal. Legacy textual suppression and direct-messaging instructions are obsolete."
);
let config = SubAgentConfig {
target: agent_id.map(str::to_string),
prompt: prompt.to_string(),
context: Some(contract.clone()),
mode: ExecutionMode::Foreground,
allowed_tools: None,
max_iterations: None,
timeout_secs: Some(timeout_secs),
plan_item_id: None,
session_id: Some(root_session_id.clone()),
};
let mut resolution = if agent_id.is_some() {
let mut resolution = self.manager.resolve_agent(&config, &caller, &run_id)?;
resolution
.tools
.register(crate::tools::CompleteScheduledRunTool::new());
resolution.tool_context.session_id = Some(root_session_id.clone());
resolution.tool_context.scheduled_completion = Some(sink.clone());
resolution.timeout_secs = resolution.timeout_secs.min(timeout_secs);
resolution.signal_contract = None;
resolution
} else {
self.manager
.resolve_scheduled_root(&caller, &run_id, timeout_secs, sink.clone())?
};
resolution.tool_context.execution_origin =
crate::tools::ExecutionOrigin::Scheduled { job_run_id };
let now = chrono::Utc::now().timestamp_millis();
let deadline_at = now.saturating_add((resolution.timeout_secs * 1000) as i64);
let new_run = NewAgentRun {
id: run_id.clone(),
root_session_id,
root_turn_id: None,
parent_run_id: None,
caller_agent_id: "SCHEDULER".to_string(),
caller_scope_id: format!("scheduled:{job_id}"),
idempotency_key: Some(format!("scheduled:{job_run_id}")),
agent_id: agent_id.unwrap_or("ROOT").to_string(),
definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
provider_name: resolution.provider_config.name.clone(),
model_id: resolution.provider_config.model_id.clone(),
mode: AgentRunMode::Foreground,
depth: 1,
plan_item_id: None,
execution_id: run_id.clone(),
task: prompt.to_string(),
context_json: None,
budget_json: serde_json::json!({
"remaining_runs": self.manager.catalog().max_runs_per_tree().saturating_sub(1),
"remaining_depth": self.manager.catalog().max_tree_depth().saturating_sub(1),
})
.to_string(),
signal_contract_json: None,
signal_delivery: None,
deadline_at,
runtime_generation: self.runtime_generation,
completion_slot_reserved: false,
};
match self
.storage
.accept_agent_runs(AcceptAgentRequest {
runs: vec![new_run],
now,
})
.await?
{
AcceptedAgentRuns::Accepted { .. } => {}
AcceptedAgentRuns::Existing { .. } => {
return Err(CoordinatorError::Rejected(format!(
"scheduled occurrence {job_run_id} already has an Agent run"
)));
}
}
let mut mark_attempt = 0_u64;
let marked = match loop {
match self
.storage
.mark_scheduled_run_running(job_run_id, lease_owner, Some(&run_id), now)
.await
{
Err(error) if error.is_transient() && mark_attempt < 2 => {
mark_attempt += 1;
tokio::time::sleep(std::time::Duration::from_millis(50 * mark_attempt)).await;
}
result => break result,
}
} {
Ok(marked) => marked,
Err(error) => {
let _ = self
.storage
.cancel_agent_run_with_completion(
&run_id,
"scheduled occurrence could not enter running state",
true,
now,
)
.await;
return Err(error.into());
}
};
if !marked {
let _ = self
.storage
.cancel_agent_run_with_completion(
&run_id,
"scheduled occurrence was no longer active",
true,
now,
)
.await;
return Err(CoordinatorError::Rejected(format!(
"scheduled occurrence {job_run_id} lost its lease"
)));
}
let (execution, agent_terminal) = match self
.execute_scheduled_agent_run(&run_id, &config, resolution)
.await
{
Ok(execution) => execution,
Err(error) => (
Err(error),
AgentTerminalOutcome::Failed {
error: "scheduled Agent could not enter its execution lifecycle".to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: Vec::new(),
},
),
};
let status = match &execution {
Ok(result) => match &result.status {
TaskStatus::Completed => crate::storage::ScheduledRunStatus::Completed,
TaskStatus::Failed(_) => crate::storage::ScheduledRunStatus::Failed,
TaskStatus::TimedOut => crate::storage::ScheduledRunStatus::TimedOut,
TaskStatus::Cancelled => crate::storage::ScheduledRunStatus::Interrupted,
},
Err(_) => crate::storage::ScheduledRunStatus::Failed,
};
let error = match &execution {
Ok(result) => match &result.status {
TaskStatus::Completed => None,
TaskStatus::Failed(_) => Some(
"scheduled Agent execution failed; inspect Gateway logs for details"
.to_string(),
),
TaskStatus::TimedOut => Some("scheduled Agent timed out".to_string()),
TaskStatus::Cancelled => Some("scheduled Agent was cancelled".to_string()),
},
Err(_) => Some(
"scheduled Agent execution failed; inspect Gateway logs for details".to_string(),
),
};
Ok(ScheduledAgentExecution {
agent_run_id: run_id,
status,
outcome: sink.outcome(),
error,
agent_terminal,
runtime_generation: self.runtime_generation,
})
}
async fn execute_scheduled_agent_run(
self: &Arc<Self>,
run_id: &str,
config: &SubAgentConfig,
resolution: ResolvedAgentRun,
) -> Result<
(
Result<SubAgentResult, CoordinatorError>,
AgentTerminalOutcome,
),
CoordinatorError,
> {
let execution_id = run_id.to_string();
let token = resolution.tool_context.cancellation.clone();
self.active_tokens.insert(run_id.to_string(), token);
let started = self
.storage
.mark_agent_run_running(run_id, &execution_id, chrono::Utc::now().timestamp_millis())
.await?;
if !started {
self.active_tokens.remove(run_id);
return Err(CoordinatorError::Rejected(format!(
"scheduled Agent run {run_id} was closed before execution started"
)));
}
let result = self
.manager
.execute_resolved(config, resolution, run_id)
.await
.map_err(CoordinatorError::SubAgent);
self.active_tokens.remove(run_id);
let terminal = match &result {
Ok(result) => match &result.status {
TaskStatus::Completed => AgentTerminalOutcome::Completed {
result: result.full_content.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
tool_calls: result.tool_calls_count as i64,
iterations: result.iterations as i64,
signal_ids: Vec::new(),
},
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
error: error.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: Vec::new(),
},
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
deadline_at: chrono::Utc::now().timestamp_millis(),
signal_ids: Vec::new(),
},
TaskStatus::Cancelled => AgentTerminalOutcome::Interrupted {
reason: "scheduled Agent interrupted by shutdown".to_string(),
signal_ids: Vec::new(),
},
},
Err(error) => AgentTerminalOutcome::Failed {
error: error.to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: Vec::new(),
},
};
Ok((result, terminal))
}
/// Admit a named background run for the root caller and spawn its runner.
/// Completion is guaranteed by the reserved inbox slot; the returned ID
/// is only valid when every durable step succeeded.
/// Admit one or more named background runs and spawn their runners.
/// Returns immediately: run quota is acquired inside each runner (queuing
/// time counts toward the run timeout), and completion capacity is
/// reserved up front so no completion can ever be lost.
pub async fn delegate_background(
self: &Arc<Self>,
caller: &ToolExecutionContext,
configs: Vec<SubAgentConfig>,
) -> Result<BackgroundAdmission, CoordinatorError> {
if caller.execution_origin.is_scheduled() {
return Err(CoordinatorError::Rejected(
"scheduled Agents cannot create background runs".to_string(),
));
}
if caller.agent.is_some() {
return Err(CoordinatorError::Rejected(
"nested background runs are not available yet; only the root Agent may delegate background work".to_string(),
));
}
if configs.is_empty() {
return Err(CoordinatorError::Rejected(
"background delegation requires at least one task".to_string(),
));
}
// Hard cap: a batch larger than the run quota would never execute
// concurrently, so reject it up front.
if configs.len() > self.execution_gate.max_concurrent_runs() {
return Err(CoordinatorError::Rejected(format!(
"background batch of {} runs exceeds max_concurrent_runs ({})",
configs.len(),
self.execution_gate.max_concurrent_runs()
)));
}
let root_session_id = caller
.session_id
.clone()
.or_else(|| {
caller
.agent
.as_ref()
.map(|agent| agent.root_session_id.clone())
})
.ok_or_else(|| {
CoordinatorError::Rejected("delegate requires a session-bound context".to_string())
})?;
let now = chrono::Utc::now().timestamp_millis();
// Resolve every target before any durable write so a bad request
// fails closed without leaving orphan rows.
let mut run_ids = Vec::with_capacity(configs.len());
let mut resolved = Vec::with_capacity(configs.len());
for config in &configs {
if config.target.is_none() {
return Err(CoordinatorError::Rejected(
"named background targets only".to_string(),
));
}
let run_id = Uuid::new_v4().to_string();
resolved.push(self.manager.resolve_agent(config, caller, &run_id)?);
run_ids.push(run_id);
}
// 1. Reserve one completion slot per run; failure rejects the whole
// batch so nothing is admitted under capacity.
if self
.storage
.reserve_completion_slots(
&root_session_id,
configs.len() as i64,
self.max_pending_inbox_events_per_session,
now,
)
.await?
.is_none()
{
return Err(CoordinatorError::Rejected(
"inbox capacity exceeded; cannot accept the background batch".to_string(),
));
}
// 2. Persist the queued runs atomically with the reservation.
let mut runs = Vec::with_capacity(configs.len());
for (index, config) in configs.iter().enumerate() {
let resolution = &resolved[index];
runs.push(NewAgentRun {
id: run_ids[index].clone(),
root_session_id: root_session_id.clone(),
root_turn_id: caller.turn_id.clone(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
caller_scope_id: "ROOT".to_string(),
idempotency_key: None,
agent_id: resolution.agent_id.clone().unwrap_or_default(),
definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
provider_name: resolution.provider_config.name.clone(),
model_id: resolution.provider_config.model_id.clone(),
mode: AgentRunMode::Background,
depth: 1,
plan_item_id: config.plan_item_id.clone(),
execution_id: run_ids[index].clone(),
task: config.prompt.clone(),
context_json: config.context.clone(),
budget_json: serde_json::json!({
"remaining_runs": self.manager.catalog().max_runs_per_tree(),
"remaining_depth": self.manager.catalog().max_tree_depth(),
})
.to_string(),
signal_contract_json: resolution
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolution
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at: now + (resolution.timeout_secs * 1000) as i64,
runtime_generation: self.runtime_generation,
completion_slot_reserved: true,
});
}
match self
.storage
.accept_agent_runs(AcceptAgentRequest { runs, now })
.await?
{
AcceptedAgentRuns::Accepted { .. } => {}
AcceptedAgentRuns::Existing { .. } => {
self.storage
.release_completion_slots(&root_session_id, configs.len() as i64, now)
.await?;
return Err(CoordinatorError::Rejected(
"background admission conflicted with an existing run id".to_string(),
));
}
}
// 3. Spawn one runner per run. Each runner acquires its own run
// quota permit and admission guard; delegate returns immediately.
let coordinator = self.clone();
let mut spawned_ids = Vec::with_capacity(configs.len());
for (index, config) in configs.iter().enumerate() {
let run_id = run_ids[index].clone();
let token = CancellationToken::new();
self.active_tokens.insert(run_id.clone(), token.clone());
let config = config.clone();
let resolution = resolved[index].clone();
let spawned = self
.task_supervisor
.spawn_graceful(format!("agent-run:{run_id}"), {
let coordinator = coordinator.clone();
let run_id = run_id.clone();
async move {
coordinator
.run_background_runner(&run_id, &config, resolution, token)
.await;
}
});
if !spawned {
// Compensation: the rejected closure was dropped by the
// supervisor. Cancel the run and release its slot.
self.active_tokens.remove(&run_id);
let _ = self
.storage
.cancel_agent_run_with_completion(&run_id, "gateway shutdown", true, now)
.await;
continue;
}
spawned_ids.push(run_id);
}
if spawned_ids.is_empty() {
return Err(CoordinatorError::Rejected(
"gateway is shutting down and cannot accept background tasks".to_string(),
));
}
Ok(BackgroundAdmission {
run_ids: spawned_ids,
})
}
async fn run_background_runner(
self: &Arc<Self>,
run_id: &str,
config: &SubAgentConfig,
resolved: ResolvedAgentRun,
token: CancellationToken,
) {
let now = chrono::Utc::now().timestamp_millis();
let execution_id = run_id.to_string();
let root_session_id = resolved
.tool_context
.agent
.as_ref()
.map(|agent| agent.root_session_id.clone())
.unwrap_or_default();
// Run quota + admission guard, acquired inside the runner so
// `delegate_background` returns immediately. Queuing time counts
// toward the run timeout and cancellation aborts the wait.
let run_permit = match self
.execution_gate
.acquire_run(&root_session_id, &token)
.await
{
Ok(permit) => permit,
Err(_) => {
let _ = self
.storage
.cancel_agent_run_with_completion(run_id, "cancelled before start", true, now)
.await;
self.active_tokens.remove(run_id);
return;
}
};
let Some(activity) = self.admission.try_enter() else {
drop(run_permit);
let _ = self
.storage
.cancel_agent_run_with_completion(run_id, "gateway shutdown", true, now)
.await;
self.active_tokens.remove(run_id);
return;
};
let _run_permit = run_permit;
let _activity = activity;
if !self
.storage
.mark_agent_run_running(run_id, &execution_id, now)
.await
.unwrap_or(false)
{
// Cancelled before start; the canceller already resolved the
// reservation and completion.
self.active_tokens.remove(run_id);
return;
}
let emitted_signals: Vec<String> = resolved
.tool_context
.agent
.as_ref()
.and_then(|agent| agent.emitted_signals.lock().ok())
.map(|signals| {
signals
.iter()
.map(|signal| signal.signal_id.clone())
.collect()
})
.unwrap_or_default();
let result = self
.manager
.execute_resolved(config, resolved, run_id)
.await;
let outcome = match &result {
Ok(result) => match &result.status {
TaskStatus::Completed => AgentTerminalOutcome::Completed {
result: result.full_content.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
tool_calls: result.tool_calls_count as i64,
iterations: result.iterations as i64,
signal_ids: emitted_signals.clone(),
},
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
error: error.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals.clone(),
},
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
deadline_at: chrono::Utc::now().timestamp_millis(),
signal_ids: emitted_signals.clone(),
},
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
reason: "cancelled by user, parent or shutdown".to_string(),
signal_ids: emitted_signals.clone(),
},
},
Err(error) => AgentTerminalOutcome::Failed {
error: error.to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals,
},
};
let summary = result
.as_ref()
.ok()
.map(|result| truncate_summary(&result.full_content));
match self
.storage
.commit_agent_terminal(
run_id,
&execution_id,
self.runtime_generation,
&outcome,
summary.as_deref(),
chrono::Utc::now().timestamp_millis(),
)
.await
{
Ok(Some(commit)) => {
if let Some(plan_item_id) = commit.run.plan_item_id.clone() {
self.refresh_work_plan(&commit.run.root_session_id, Some(plan_item_id));
}
// The completion event (if any) was committed; wake the
// session so it claims the inbox soon. A lost wake is not
// fatal — the next claim pass finds the event anyway.
self.projection.publish(AgentProjection {
session_id: commit.run.root_session_id.clone(),
revision: commit.run.revision,
run: Some(crate::protocol::AgentRunView::from_record(
&commit.run,
2000,
)),
event: None,
});
self.notifier
.notify(&commit.run.root_session_id, commit.run.revision)
.await;
}
Ok(None) => {
tracing::warn!(
run_id,
"late background result discarded by terminal commit"
);
}
Err(error) => {
tracing::error!(run_id, error = %error, "background terminal commit failed");
}
}
self.active_tokens.remove(run_id);
drop(token);
}
/// Cancel every nonterminal run of a session (archive/delete or `/stop`).
/// Completion events are written consumed so no continuation starts after
/// the session was closed.
pub async fn cancel_session(
&self,
session_id: &str,
reason: &str,
) -> Result<usize, CoordinatorError> {
let runs = self.storage.list_agent_runs(session_id, None, 200).await?;
let mut count = 0;
for run in runs {
if run.status.is_terminal() {
continue;
}
if self
.storage
.cancel_agent_run_with_completion(
&run.id,
reason,
true,
chrono::Utc::now().timestamp_millis(),
)
.await?
{
if let Some((_, token)) = self.active_tokens.remove(&run.id) {
token.cancel();
}
count += 1;
}
}
Ok(count)
}
/// Startup/activation recovery: interrupt runs of older generations,
/// expire stale leases and reconcile the per-session capacity rows.
/// Safe to call once per activation.
pub async fn recover_on_activation(
&self,
) -> Result<crate::storage::agent_inbox::RecoveryReport, CoordinatorError> {
let now = chrono::Utc::now().timestamp_millis();
let report = self
.storage
.recover_agent_state(
self.runtime_generation,
now,
self.max_inbox_delivery_attempts,
60_000,
)
.await?;
// One merged wake per session with due events; the notifier is the
// accelerator and a dead target is not an error (the periodic timer
// inside each live worker re-claims anyway).
if report.interrupted_runs > 0 || report.leases_expired > 0 {
for session_id in self.storage.sessions_with_due_events(now).await? {
self.notifier.notify(&session_id, now).await;
}
}
Ok(report)
}
/// Execute a foreground delegation batch with durable run persistence.
/// Results keep request order even though runs execute concurrently.
pub async fn delegate_foreground(
self: &Arc<Self>,
caller: &ToolExecutionContext,
configs: Vec<SubAgentConfig>,
) -> Result<Vec<SubAgentResult>, CoordinatorError> {
if configs.is_empty() {
return Err(CoordinatorError::Rejected(
"foreground delegation requires at least one task".to_string(),
));
}
if configs
.iter()
.any(|config| config.mode != ExecutionMode::Foreground)
{
return Err(CoordinatorError::Rejected(
"coordinator foreground path received a non-foreground request".to_string(),
));
}
// Resolve every target before persisting anything so a bad request
// fails closed without leaving orphan rows.
let mut run_ids = Vec::with_capacity(configs.len());
let mut resolved: Vec<ResolvedAgentRun> = Vec::with_capacity(configs.len());
for config in &configs {
if config.target.is_none() {
return Err(CoordinatorError::Rejected(
"legacy general Agent is not persisted; named targets only".to_string(),
));
}
let run_id = Uuid::new_v4().to_string();
resolved.push(self.manager.resolve_agent(config, caller, &run_id)?);
run_ids.push(run_id);
}
let root_session_id = caller
.agent
.as_ref()
.map(|agent| agent.root_session_id.clone())
.or_else(|| caller.session_id.clone())
.ok_or_else(|| {
CoordinatorError::Rejected("delegate requires a session-bound context".to_string())
})?;
let now = chrono::Utc::now().timestamp_millis();
let caller_scope_id = caller
.turn_id
.clone()
.or_else(|| caller.agent.as_ref().map(|agent| agent.run_id.clone()))
.unwrap_or_else(|| "root".to_string());
let mut runs = Vec::with_capacity(configs.len());
for (index, config) in configs.iter().enumerate() {
let resolution = &resolved[index];
let deadline_at = now + (resolution.timeout_secs * 1000) as i64;
runs.push(NewAgentRun {
id: run_ids[index].clone(),
root_session_id: root_session_id.clone(),
root_turn_id: caller.turn_id.clone(),
parent_run_id: caller.agent.as_ref().map(|agent| agent.run_id.clone()),
caller_agent_id: caller
.agent
.as_ref()
.map(|agent| agent.current_agent_id.clone())
.unwrap_or_else(|| "ROOT".to_string()),
caller_scope_id: caller_scope_id.clone(),
idempotency_key: None,
agent_id: resolution.agent_id.clone().unwrap_or_default(),
definition_hash: resolution.definition_hash.clone().unwrap_or_default(),
provider_profile: resolution.llm_profile.clone().unwrap_or_default(),
provider_name: resolution.provider_config.name.clone(),
model_id: resolution.provider_config.model_id.clone(),
mode: AgentRunMode::Foreground,
depth: caller
.agent
.as_ref()
.map_or(1, |agent| agent.depth.saturating_add(1) as i64),
plan_item_id: config.plan_item_id.clone(),
execution_id: run_ids[index].clone(),
task: config.prompt.clone(),
context_json: config.context.clone(),
budget_json: serde_json::to_string(&serde_json::json!({
"remaining_runs": caller.agent.as_ref().map(|agent| agent.budget.remaining_runs),
"remaining_depth": caller.agent.as_ref().map(|agent| agent.budget.remaining_depth),
}))
.unwrap_or_default(),
signal_contract_json: resolution
.signal_contract
.as_ref()
.map(|contract| serde_json::to_string(contract).unwrap_or_default()),
signal_delivery: resolution
.signal_contract
.as_ref()
.map(|contract| contract.delivery.as_str().to_string()),
deadline_at,
runtime_generation: self.runtime_generation,
completion_slot_reserved: false,
});
}
match self
.storage
.accept_agent_runs(AcceptAgentRequest { runs, now })
.await?
{
AcceptedAgentRuns::Accepted { .. } => {}
AcceptedAgentRuns::Existing { .. } => {
return Err(CoordinatorError::Rejected(
"foreground delegation conflicted with an existing run id".to_string(),
));
}
}
// A named parent waits structurally for its children: it moves to
// waiting_children and holds no step permits while waiting.
let parent = caller.agent.clone();
if let Some(parent) = parent.as_ref() {
let _ = self
.storage
.mark_agent_run_waiting_children(
&parent.run_id,
&parent.execution_id,
AgentRunStatus::Running,
chrono::Utc::now().timestamp_millis(),
)
.await;
}
let futures: Vec<_> = configs
.iter()
.enumerate()
.map(|(index, config)| {
let coordinator = self.clone();
let run_id = run_ids[index].clone();
let resolution = resolved[index].clone();
let config = config.clone();
async move { coordinator.execute_run(&run_id, &config, resolution).await }
})
.collect();
let results = futures_util::future::join_all(futures)
.await
.into_iter()
.enumerate()
.map(|(index, result)| {
result.unwrap_or_else(|error| SubAgentResult {
task_id: run_ids[index].clone(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: TaskStatus::Failed(error.to_string()),
tool_calls_count: 0,
iterations: 0,
duration_ms: 0,
})
})
.collect();
if let Some(parent) = parent.as_ref() {
let _ = self
.storage
.restore_agent_run_running(
&parent.run_id,
&parent.execution_id,
chrono::Utc::now().timestamp_millis(),
)
.await;
}
Ok(results)
}
async fn execute_run(
self: &Arc<Self>,
run_id: &str,
config: &SubAgentConfig,
resolution: ResolvedAgentRun,
) -> Result<SubAgentResult, CoordinatorError> {
let execution_id = run_id.to_string();
let token = resolution.tool_context.cancellation.clone();
self.active_tokens.insert(run_id.to_string(), token);
let started = self
.storage
.mark_agent_run_running(run_id, &execution_id, chrono::Utc::now().timestamp_millis())
.await?;
if !started {
self.active_tokens.remove(run_id);
let status = self
.storage
.get_agent_run(run_id)
.await?
.map(|run| run.status)
.unwrap_or(AgentRunStatus::Cancelled);
return Ok(SubAgentResult {
task_id: run_id.to_string(),
content: String::new(),
content_truncated: false,
full_content: String::new(),
status: match status {
AgentRunStatus::Cancelled => TaskStatus::Cancelled,
AgentRunStatus::TimedOut => TaskStatus::TimedOut,
AgentRunStatus::Interrupted => {
TaskStatus::Failed("run interrupted before start".to_string())
}
_ => TaskStatus::Failed("run was closed before execution started".to_string()),
},
tool_calls_count: 0,
iterations: 0,
duration_ms: 0,
});
}
let emitted_signals: Vec<String> = resolution
.tool_context
.agent
.as_ref()
.and_then(|agent| agent.emitted_signals.lock().ok())
.map(|signals| {
signals
.iter()
.map(|signal| signal.signal_id.clone())
.collect()
})
.unwrap_or_default();
let result = self
.manager
.execute_resolved(config, resolution, run_id)
.await;
let outcome_result = match &result {
Ok(result) => match &result.status {
TaskStatus::Completed => AgentTerminalOutcome::Completed {
result: result.full_content.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
tool_calls: result.tool_calls_count as i64,
iterations: result.iterations as i64,
signal_ids: emitted_signals.clone(),
},
TaskStatus::Failed(error) => AgentTerminalOutcome::Failed {
error: error.clone(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals.clone(),
},
TaskStatus::TimedOut => AgentTerminalOutcome::TimedOut {
deadline_at: chrono::Utc::now().timestamp_millis(),
signal_ids: emitted_signals.clone(),
},
TaskStatus::Cancelled => AgentTerminalOutcome::Cancelled {
reason: "cancelled by user, parent or shutdown".to_string(),
signal_ids: emitted_signals.clone(),
},
},
Err(error) => AgentTerminalOutcome::Failed {
error: error.to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
signal_ids: emitted_signals,
},
};
let summary = result
.as_ref()
.ok()
.map(|result| truncate_summary(&result.full_content));
let commit = self
.storage
.commit_agent_terminal(
run_id,
&execution_id,
self.runtime_generation,
&outcome_result,
summary.as_deref(),
chrono::Utc::now().timestamp_millis(),
)
.await?;
self.active_tokens.remove(run_id);
match commit {
Some(commit) => {
if commit.run.plan_item_id.is_some() {
self.refresh_work_plan(
&commit.run.root_session_id,
commit.run.plan_item_id.clone(),
);
}
}
None => {
tracing::warn!(run_id, "late agent run result discarded by terminal commit");
}
}
result.map_err(CoordinatorError::SubAgent)
}
/// The plan item was already mutated inside the Storage transaction; this
/// only re-reads, refreshes the WorkManager cache and broadcasts.
fn refresh_work_plan(&self, session_id: &str, item_id: Option<String>) {
let Some(work_manager) = self.work_manager.as_ref() else {
return;
};
let Some(item_id) = item_id else {
return;
};
let work_manager = work_manager.clone();
let session_id = session_id.to_string();
tokio::spawn(async move {
if let Err(error) = work_manager
.refresh_after_external_commit(&session_id, "agent_run", vec![item_id])
.await
{
tracing::warn!(error = %error, "failed to refresh plan after agent run commit");
}
});
}
/// Cancel a nonterminal run owned by the caller's session/tree.
pub async fn cancel_run(
&self,
caller: &ToolExecutionContext,
run_id: &str,
reason: &str,
) -> Result<bool, CoordinatorError> {
self.cancel_run_inner(caller, run_id, reason, false).await
}
/// `suppress_continuation` writes the completion event consumed so no
/// continuation Turn restarts after lifecycle cancellation (`/stop`).
pub(crate) async fn cancel_run_inner(
&self,
caller: &ToolExecutionContext,
run_id: &str,
reason: &str,
suppress_continuation: bool,
) -> Result<bool, CoordinatorError> {
let Some(run) = self.storage.get_agent_run(run_id).await? else {
return Ok(false);
};
self.authorize_access(caller, &run).await?;
if run.status.is_terminal() {
return Ok(false);
}
let cancelled = self
.storage
.cancel_agent_run_with_completion(
run_id,
reason,
suppress_continuation,
chrono::Utc::now().timestamp_millis(),
)
.await?;
if cancelled {
if let Some((_, token)) = self.active_tokens.remove(run_id) {
token.cancel();
}
// An explicit cancel supersedes the run's unconsumed signals:
// they remain as audit facts but no continuation will report
// them. Completions are never superseded.
if let Err(error) = self
.storage
.supersede_agent_events(
run_id,
AgentEventType::Signal,
chrono::Utc::now().timestamp_millis(),
)
.await
{
tracing::warn!(run_id, error = %error, "failed to supersede signals on cancel");
}
}
Ok(cancelled)
}
/// Persist one signal from a running Agent into its root session inbox.
/// The tool only exists for runs with a signal contract; this method is
/// the single writer that enforces capacity and generates the wake.
pub async fn emit_signal(
&self,
context: &AgentExecutionContext,
input: SignalInput,
) -> Result<SignalAccepted, CoordinatorError> {
let Some(run) = self.storage.get_agent_run(&context.run_id).await? else {
return Err(CoordinatorError::Rejected(
"signal rejected: run no longer exists".to_string(),
));
};
if run.execution_id != context.execution_id {
return Err(CoordinatorError::Rejected(
"signal rejected: stale execution".to_string(),
));
}
if run.status.is_terminal() {
return Err(CoordinatorError::Rejected(
"signal rejected: run is no longer active".to_string(),
));
}
let now = chrono::Utc::now().timestamp_millis();
let event = crate::tools::emit_signal::build_signal_event(
context,
&input,
Uuid::new_v4().to_string(),
run.signal_delivery
.as_deref()
.map(crate::storage::agent_inbox::AgentEventDelivery::parse)
.transpose()
.map_err(CoordinatorError::from)?
.unwrap_or(crate::storage::agent_inbox::AgentEventDelivery::Queue),
);
match self
.storage
.insert_agent_signal(&event, self.max_pending_inbox_events_per_session, now)
.await?
{
Some((record, deduplicated)) => {
if deduplicated {
return Ok(SignalAccepted {
signal_id: record.id,
status: SignalAcceptedStatus::Deduplicated,
delivery: event.delivery,
});
}
self.projection.publish(AgentProjection {
session_id: run.root_session_id.clone(),
revision: record.revision,
run: None,
event: Some(crate::protocol::AgentEventView::from_record(&record)),
});
self.notifier
.notify(&run.root_session_id, record.revision)
.await;
Ok(SignalAccepted {
signal_id: record.id,
status: SignalAcceptedStatus::Accepted,
delivery: event.delivery,
})
}
None => Err(CoordinatorError::Rejected(
"inbox capacity exceeded; signal rejected".to_string(),
)),
}
}
pub async fn get_run(
&self,
caller: &ToolExecutionContext,
run_id: &str,
) -> Result<Option<AgentRunRecord>, CoordinatorError> {
let Some(run) = self.storage.get_agent_run(run_id).await? else {
return Ok(None);
};
self.authorize_access(caller, &run).await?;
Ok(Some(run))
}
pub async fn list_runs(
&self,
caller: &ToolExecutionContext,
cursor: Option<(i64, String)>,
limit: i64,
) -> Result<Vec<AgentRunRecord>, CoordinatorError> {
let session_id = caller_session(caller)?;
self.storage
.list_agent_runs(&session_id, cursor, limit)
.await
.map_err(CoordinatorError::Storage)
}
/// Session-scoped run projection for management/WebSocket clients.
pub async fn list_runs_for_session(
&self,
session_id: &str,
cursor: Option<(i64, String)>,
limit: i64,
) -> Result<(i64, Vec<crate::protocol::AgentRunView>, Option<String>), CoordinatorError> {
let runs = self
.storage
.list_agent_runs(session_id, cursor, limit)
.await?;
let next_cursor = runs
.last()
.map(|run| format!("{}:{}", run.created_at, run.id));
let views = runs
.iter()
.map(|run| crate::protocol::AgentRunView::from_record(run, 2_000))
.collect();
let revision = self.storage.get_session_agent_revision(session_id).await?;
Ok((revision, views, next_cursor))
}
/// Session-scoped single-run projection.
pub async fn get_run_for_session(
&self,
session_id: &str,
run_id: &str,
) -> Result<(i64, Option<crate::protocol::AgentRunView>), CoordinatorError> {
let revision = self.storage.get_session_agent_revision(session_id).await?;
let run = self.storage.get_agent_run(run_id).await?;
let run = run.filter(|run| run.root_session_id == session_id);
Ok((
revision,
run.map(|run| crate::protocol::AgentRunView::from_record(&run, 2_000)),
))
}
/// Inbox events of one run (audit/projection).
pub async fn list_run_events(
&self,
run_id: &str,
limit: i64,
) -> Result<Vec<crate::storage::agent_inbox::AgentInboxEventRecord>, CoordinatorError> {
self.storage
.list_agent_inbox_events_for_run(run_id, limit)
.await
.map_err(CoordinatorError::Storage)
}
/// Management-API cancel: session-scoped, suppresses continuation so a
/// cancelled run never restarts a background Turn by itself.
pub async fn cancel_run_for_session(
&self,
session_id: &str,
run_id: &str,
reason: &str,
) -> Result<bool, CoordinatorError> {
let Some(run) = self.storage.get_agent_run(run_id).await? else {
return Ok(false);
};
if run.root_session_id != session_id {
return Err(CoordinatorError::Rejected(
"run does not belong to this session".to_string(),
));
}
if run.status.is_terminal() {
return Ok(false);
}
let caller = ToolExecutionContext::for_session(session_id);
self.cancel_run_inner(&caller, run_id, reason, true).await
}
/// Full durable result for a finished run; `None` while nonterminal.
pub async fn get_result(
&self,
caller: &ToolExecutionContext,
run_id: &str,
) -> Result<Option<AgentRunRecord>, CoordinatorError> {
let Some(run) = self.get_run(caller, run_id).await? else {
return Ok(None);
};
if run.status.is_terminal() {
Ok(Some(run))
} else {
Ok(None)
}
}
/// Root may access every run of its session; a named Agent may only
/// access its own run and descendants. Run IDs are never credentials.
async fn authorize_access(
&self,
caller: &ToolExecutionContext,
run: &AgentRunRecord,
) -> Result<(), CoordinatorError> {
let Some(agent) = caller.agent.as_ref() else {
let session_id = caller_session(caller)?;
if run.root_session_id != session_id {
return Err(CoordinatorError::Rejected(
"run belongs to a different session".to_string(),
));
}
return Ok(());
};
if run.root_session_id != agent.root_session_id {
return Err(CoordinatorError::Rejected(
"run belongs to a different session".to_string(),
));
}
let mut current = run.clone();
loop {
if current.id == agent.run_id {
return Ok(());
}
let Some(parent_id) = current.parent_run_id.clone() else {
return Err(CoordinatorError::Rejected(
"run is not part of the caller's delegation tree".to_string(),
));
};
let Some(parent) = self.storage.get_agent_run(&parent_id).await? else {
return Err(CoordinatorError::Rejected(
"run ancestry is corrupt".to_string(),
));
};
current = parent;
}
}
}
fn caller_session(caller: &ToolExecutionContext) -> Result<String, CoordinatorError> {
caller
.agent
.as_ref()
.map(|agent| agent.root_session_id.clone())
.or_else(|| caller.session_id.clone())
.ok_or_else(|| {
CoordinatorError::Rejected("caller context is not session-bound".to_string())
})
}
fn truncate_summary(content: &str) -> String {
const MAX: usize = 500;
if content.len() <= MAX {
content.to_string()
} else {
let cut = content.floor_char_boundary(MAX);
format!("{}...", &content[..cut])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::AgentCatalog;
use crate::config::LLMProviderConfig;
use crate::tools::ToolRegistry;
use std::collections::HashMap;
fn provider_config() -> LLMProviderConfig {
LLMProviderConfig {
provider_type: "openai".into(),
name: "test".into(),
base_url: "http://localhost".into(),
api_key: "test".into(),
extra_headers: HashMap::new(),
model_id: "test".into(),
temperature: None,
max_tokens: None,
model_extra: HashMap::new(),
max_tool_iterations: 1,
token_limit: 4096,
workspace_dir: std::env::temp_dir(),
input_types: vec!["text".into()],
price_input_per_million: None,
price_output_per_million: None,
}
}
fn write_catalog(root: &std::path::Path) -> AgentCatalog {
std::fs::create_dir_all(root.join("agents")).unwrap();
std::fs::write(
root.join("agents/researcher.md"),
"---\nid: researcher\ndescription: research role\nllm_profile: research\n---\n# Role\n\nDo the assigned work.\n",
)
.unwrap();
let tools = ToolRegistry::new();
let loader = crate::skills::SkillsLoader::new_for_testing(
root.join("skills"),
root.join("external-skills"),
);
let profiles = HashMap::from([("research".to_string(), provider_config())]);
let config = crate::config::AgentOrchestrationConfig {
definitions_dir: "agents".to_string(),
..Default::default()
};
AgentCatalog::load(
&config,
root,
&profiles,
&HashMap::new(),
&HashMap::new(),
root,
&tools,
&loader,
1,
)
.unwrap()
}
async fn coordinator() -> (Arc<AgentCoordinator>, tempfile::TempDir) {
coordinator_with_inbox_limit(1).await
}
async fn coordinator_with_inbox_limit(
max_pending: usize,
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
coordinator_with_inbox_and_run_limit(max_pending, None).await
}
async fn coordinator_with_inbox_and_run_limit(
max_pending: usize,
max_concurrent_runs: Option<usize>,
) -> (Arc<AgentCoordinator>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(Storage::new(&dir.path().join("coord.db")).await.unwrap());
let catalog = write_catalog(dir.path());
let manager = Arc::new(
SubAgentManager::new(
provider_config(),
Arc::new(ToolRegistry::new()),
Some(storage.clone()),
None,
)
.with_catalog(Arc::new(catalog)),
);
let work_manager = Arc::new(crate::work::WorkManager::new(storage.clone()));
let notifier = crate::agent::AgentInboxNotifier::new();
let supervisor = crate::task_supervisor::TaskSupervisor::new();
let orchestration = crate::config::AgentOrchestrationConfig {
max_pending_inbox_events_per_session: max_pending,
..Default::default()
};
let gate = match max_concurrent_runs {
Some(limit) => {
let config = crate::config::AgentOrchestrationConfig {
max_concurrent_runs: limit,
..Default::default()
};
crate::agent::gate::ExecutionGate::new(&config)
}
None => crate::agent::gate::ExecutionGate::unbounded(),
};
(
AgentCoordinator::new(
storage,
manager,
work_manager,
notifier,
Arc::new(crate::agent::AgentProjectionHub::new()),
gate,
crate::gateway::reload::RuntimeAdmission::open(),
supervisor,
1,
&orchestration,
),
dir,
)
}
fn foreground_config(target: &str) -> SubAgentConfig {
SubAgentConfig {
target: Some(target.to_string()),
prompt: "work".to_string(),
context: None,
mode: ExecutionMode::Foreground,
allowed_tools: None,
max_iterations: Some(1),
timeout_secs: Some(5),
plan_item_id: None,
session_id: Some("cli:test:dialog".to_string()),
}
}
#[tokio::test]
async fn foreground_run_is_persisted_with_terminal_state() {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let results = coordinator
.delegate_foreground(&caller, vec![foreground_config("researcher")])
.await
.unwrap();
assert_eq!(results.len(), 1);
let run_id = &results[0].task_id;
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
assert_eq!(run.agent_id, "researcher");
assert_eq!(run.mode, AgentRunMode::Foreground);
// Provider is unreachable in this test, so the run must fail — but it
// must still be persisted with a terminal status and full audit row.
assert!(run.status.is_terminal());
assert!(matches!(run.status, AgentRunStatus::Failed));
assert!(run.finished_at.is_some());
}
#[tokio::test]
async fn batch_creates_group_and_keeps_request_order() {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let results = coordinator
.delegate_foreground(
&caller,
vec![
foreground_config("researcher"),
foreground_config("researcher"),
],
)
.await
.unwrap();
assert_eq!(results.len(), 2);
let first = coordinator
.get_run(&caller, &results[0].task_id)
.await
.unwrap()
.unwrap();
let second = coordinator
.get_run(&caller, &results[1].task_id)
.await
.unwrap()
.unwrap();
assert_ne!(first.id, second.id);
}
#[tokio::test]
async fn other_session_cannot_read_or_cancel_runs() {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let results = coordinator
.delegate_foreground(&caller, vec![foreground_config("researcher")])
.await
.unwrap();
let run_id = results[0].task_id.clone();
let stranger = ToolExecutionContext::for_session("cli:other:dialog");
let error = coordinator.get_run(&stranger, &run_id).await.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
let error = coordinator
.cancel_run(&stranger, &run_id, "x")
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
}
#[tokio::test]
async fn unknown_target_is_rejected_before_persistence() {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let error = coordinator
.delegate_foreground(&caller, vec![foreground_config("missing")])
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::SubAgent(_)));
assert!(
coordinator
.list_runs(&caller, None, 10)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn background_admission_persists_and_reserves_completion() {
let (coordinator, _dir) = coordinator().await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let admission = coordinator
.delegate_background(&caller, vec![foreground_config("researcher")])
.await
.unwrap();
assert_eq!(admission.run_ids.len(), 1);
let run_id = &admission.run_ids[0];
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
assert_eq!(run.mode, AgentRunMode::Background);
assert!(run.completion_slot_reserved);
// A second background run while the inbox is at capacity must be
// rejected, not admitted silently.
let error = coordinator
.delegate_background(&caller, vec![foreground_config("researcher")])
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
// Wait for the spawned runner to finish (provider is unreachable, so
// it fails fast) and verify the terminal commit converted the
// reservation into a durable completion event.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let run = coordinator.get_run(&caller, run_id).await.unwrap().unwrap();
if run.status.is_terminal() {
break;
}
assert!(
std::time::Instant::now() < deadline,
"background runner did not finish in time"
);
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let events = coordinator
.storage
.list_agent_inbox_events("cli:test:dialog", 10)
.await
.unwrap();
assert_eq!(events.len(), 1);
assert_eq!(
events[0].status,
crate::storage::agent_inbox::AgentEventStatus::Pending
);
assert!(events[0].requires_continuation);
let state: (i64, i64) = sqlx::query_as(
"SELECT pending_event_count, reserved_completion_slots FROM agent_session_state \
WHERE root_session_id = 'cli:test:dialog'",
)
.fetch_one(coordinator.storage.pool())
.await
.unwrap();
// Reservation converted: nothing reserved, one event pending.
assert_eq!(state, (1, 0));
}
#[tokio::test]
async fn background_batch_admits_all_runs_and_creates_a_group() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let configs = vec![
foreground_config("researcher"),
foreground_config("researcher"),
foreground_config("researcher"),
];
let admission = coordinator
.delegate_background(&caller, configs)
.await
.unwrap();
assert_eq!(admission.run_ids.len(), 3);
let runs = coordinator.list_runs(&caller, None, 10).await.unwrap();
assert_eq!(runs.len(), 3);
assert!(runs.iter().all(|run| run.completion_slot_reserved));
}
#[tokio::test]
async fn background_batch_rejects_when_exceeding_run_quota() {
let (coordinator, _dir) = coordinator_with_inbox_and_run_limit(16, Some(2)).await;
let caller = ToolExecutionContext::for_session("cli:test:dialog");
let error = coordinator
.delegate_background(
&caller,
vec![
foreground_config("researcher"),
foreground_config("researcher"),
foreground_config("researcher"),
],
)
.await
.unwrap_err();
assert!(matches!(error, CoordinatorError::Rejected(_)));
}
async fn storage_accept_run_with_delivery(
storage: &Arc<Storage>,
run_id: &str,
session: &str,
now: i64,
slot_reserved: bool,
signal_delivery: Option<&str>,
) {
let _ = storage.ensure_agent_session_state(session, now).await;
if slot_reserved {
let _ = storage.reserve_completion_slots(session, 1, 1, now).await;
}
let run = NewAgentRun {
id: run_id.to_string(),
root_session_id: session.to_string(),
root_turn_id: None,
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
caller_scope_id: "turn-1".to_string(),
idempotency_key: None,
agent_id: "researcher".to_string(),
definition_hash: "hash".to_string(),
provider_profile: "research".to_string(),
provider_name: "test".to_string(),
model_id: "test-model".to_string(),
mode: AgentRunMode::Background,
depth: 1,
plan_item_id: None,
execution_id: run_id.to_string(),
task: "work".to_string(),
context_json: None,
budget_json: "{}".to_string(),
signal_contract_json: signal_delivery.map(|_| "{}".to_string()),
signal_delivery: signal_delivery.map(str::to_string),
deadline_at: now + 100_000,
runtime_generation: 1,
completion_slot_reserved: slot_reserved,
};
let _ = storage
.accept_agent_runs(AcceptAgentRequest {
runs: vec![run],
now,
})
.await
.unwrap();
}
async fn storage_accept_run(
storage: &Arc<Storage>,
run_id: &str,
session: &str,
now: i64,
slot_reserved: bool,
) {
storage_accept_run_with_delivery(storage, run_id, session, now, slot_reserved, None).await
}
#[tokio::test]
async fn emit_signal_persists_wakes_and_respects_capacity_and_dedupe() {
let (coordinator, _dir) = coordinator().await;
let run_id = "run-sig-1";
let session = "cli:test:dialog";
storage_accept_run(
&coordinator.storage,
run_id,
session,
chrono::Utc::now().timestamp_millis(),
false,
)
.await;
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "err-rate".to_string(),
severity: "warning".to_string(),
summary: "error rate above threshold".to_string(),
details: Some(serde_json::json!({ "current": 0.071 })),
dedupe_key: Some("svc-a:err".to_string()),
event_key: format!("signal:svc-a:err:{}", 0),
};
let accepted = coordinator
.emit_signal(&context, signal.clone())
.await
.unwrap();
assert_eq!(accepted.status, SignalAcceptedStatus::Accepted);
assert!(matches!(
accepted.delivery,
crate::storage::agent_inbox::AgentEventDelivery::Queue
));
// Same dedupe key in the same window collapses to the same event.
let mut duplicate = signal.clone();
duplicate.event_key = format!("signal:svc-a:err:{}", 0);
let deduplicated = coordinator.emit_signal(&context, duplicate).await.unwrap();
assert_eq!(deduplicated.status, SignalAcceptedStatus::Deduplicated);
assert_eq!(deduplicated.signal_id, accepted.signal_id);
// A stale execution id is rejected.
let mut stale = context.clone();
stale.execution_id = "other-exec".to_string();
assert!(matches!(
coordinator.emit_signal(&stale, signal.clone()).await,
Err(CoordinatorError::Rejected(_))
));
}
#[tokio::test]
async fn terminal_commit_carries_emitted_signal_ids_in_completion_payload() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let run_id = "run-sig-carrier";
let session = "cli:test:dialog";
let now = chrono::Utc::now().timestamp_millis();
storage_accept_run(&coordinator.storage, run_id, session, now, true).await;
// Emit one signal, then commit the run terminal.
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "k".to_string(),
severity: "warning".to_string(),
summary: "s".to_string(),
details: None,
dedupe_key: None,
event_key: format!("signal:{}", uuid::Uuid::new_v4()),
};
let accepted = coordinator.emit_signal(&context, signal).await.unwrap();
context
.emitted_signals
.lock()
.unwrap()
.push(crate::agent::run::EmittedSignal {
signal_id: accepted.signal_id.clone(),
});
let signal_ids: Vec<String> = context
.emitted_signals
.lock()
.unwrap()
.iter()
.map(|signal| signal.signal_id.clone())
.collect();
coordinator
.storage
.commit_agent_terminal(
run_id,
run_id,
1,
&AgentTerminalOutcome::Completed {
result: "done".to_string(),
prompt_tokens: None,
completion_tokens: None,
cost: None,
tool_calls: 1,
iterations: 1,
signal_ids,
},
None,
now + 1,
)
.await
.unwrap();
let events = coordinator
.storage
.list_agent_inbox_events(session, 10)
.await
.unwrap();
assert_eq!(events.len(), 2);
let completion = events
.iter()
.find(|event| {
event.event_type == crate::storage::agent_inbox::AgentEventType::Completion
})
.unwrap();
let payload: serde_json::Value = serde_json::from_str(&completion.payload_json).unwrap();
assert_eq!(payload["status"], "completed");
assert_eq!(payload["signal_ids"][0], accepted.signal_id);
}
#[tokio::test]
async fn emit_signal_rejects_when_run_is_terminal_or_inbox_is_full() {
let (coordinator, _dir) = coordinator().await;
let run_id = "run-sig-2";
let session = "cli:test:dialog";
let now = chrono::Utc::now().timestamp_millis();
storage_accept_run(&coordinator.storage, run_id, session, now, false).await;
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "k".to_string(),
severity: "info".to_string(),
summary: "s".to_string(),
details: None,
dedupe_key: None,
event_key: format!("signal:{}", uuid::Uuid::new_v4()),
};
// Terminal run: rejected.
coordinator
.storage
.commit_agent_terminal(
run_id,
run_id,
1,
&AgentTerminalOutcome::Cancelled {
reason: "test".to_string(),
signal_ids: Vec::new(),
},
None,
now + 1,
)
.await
.unwrap();
assert!(matches!(
coordinator.emit_signal(&context, signal.clone()).await,
Err(CoordinatorError::Rejected(_))
));
// Capacity: a reserved completion slot exhausts the session limit.
let coordinator2 = {
let dir = tempfile::tempdir().unwrap();
let storage2 = Arc::new(Storage::new(&dir.path().join("c2.db")).await.unwrap());
let catalog = write_catalog(dir.path());
let manager = Arc::new(
SubAgentManager::new(
provider_config(),
Arc::new(ToolRegistry::new()),
Some(storage2.clone()),
None,
)
.with_catalog(Arc::new(catalog)),
);
let work_manager = Arc::new(crate::work::WorkManager::new(storage2.clone()));
let notifier = crate::agent::AgentInboxNotifier::new();
let supervisor = crate::task_supervisor::TaskSupervisor::new();
let orchestration = crate::config::AgentOrchestrationConfig {
max_pending_inbox_events_per_session: 1,
..Default::default()
};
AgentCoordinator::new(
storage2,
manager,
work_manager,
notifier,
Arc::new(crate::agent::AgentProjectionHub::new()),
crate::agent::gate::ExecutionGate::unbounded(),
crate::gateway::reload::RuntimeAdmission::open(),
supervisor,
1,
&orchestration,
)
};
storage_accept_run(&coordinator2.storage, "run-sig-3", session, now, true).await;
let context2 = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: "run-sig-3".to_string(),
execution_id: "run-sig-3".to_string(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
assert!(matches!(
coordinator2.emit_signal(&context2, signal).await,
Err(CoordinatorError::Rejected(_))
));
}
#[tokio::test]
async fn emitted_signal_uses_the_runs_persisted_delivery_lane() {
let (coordinator, _dir) = coordinator_with_inbox_limit(8).await;
let run_id = "run-sig-steer";
let session = "cli:test:dialog";
let now = chrono::Utc::now().timestamp_millis();
storage_accept_run_with_delivery(
&coordinator.storage,
run_id,
session,
now,
false,
Some("steer"),
)
.await;
let run = coordinator
.storage
.get_agent_run(run_id)
.await
.unwrap()
.unwrap();
assert_eq!(run.signal_delivery.as_deref(), Some("steer"));
let context = crate::agent::AgentExecutionContext {
root_session_id: session.to_string(),
root_turn_id: None,
run_id: run_id.to_string(),
execution_id: run_id.to_string(),
parent_run_id: None,
caller_agent_id: "ROOT".to_string(),
current_agent_id: "researcher".to_string(),
ancestry: vec!["researcher".to_string()],
depth: 1,
plan_item_id: None,
cancellation: tokio_util::sync::CancellationToken::new(),
budget: crate::agent::AgentBudget {
remaining_runs: 15,
remaining_depth: 3,
},
tree_runs: Arc::new(std::sync::atomic::AtomicUsize::new(1)),
signal_contract: None,
emitted_signals: Arc::new(std::sync::Mutex::new(Vec::new())),
};
let signal = SignalInput {
key: "k".to_string(),
severity: "info".to_string(),
summary: "s".to_string(),
details: None,
dedupe_key: None,
event_key: format!("signal:{}", uuid::Uuid::new_v4()),
};
let accepted = coordinator.emit_signal(&context, signal).await.unwrap();
assert!(matches!(
accepted.delivery,
crate::storage::agent_inbox::AgentEventDelivery::Steer
));
let event = coordinator
.storage
.get_agent_inbox_event(&accepted.signal_id)
.await
.unwrap()
.unwrap();
assert_eq!(
event.delivery,
crate::storage::agent_inbox::AgentEventDelivery::Steer
);
}
}