Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 24 additions & 19 deletions crates/aionui-team/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use crate::prompt_dump::TeamPromptDumpConfig;
use crate::provisioning::{TeamAgentProvisioner, TeamConversationProvisioningPort};
use crate::session::{AgentMessageQueueResult, TeamSession, attach_member_runtime, spawn_attach_agent_process_bg};
use crate::team_run::TeamRunManager;
use crate::types::{Team, TeamAgent, TeammateRole};
use crate::types::{TaskStatus, Team, TeamAgent, TeammateRole};
use crate::work_coordinator::RuntimeConstraint;
use crate::work_source::WorkSource;
use crate::workspace::validate_create_workspace_path;
Expand Down Expand Up @@ -1751,6 +1751,29 @@ impl TeamSessionService {
continue;
}

// A team may have no currently-running turn while work is still
// assigned on the task board. Do not tear down its session in
// that gap: task-board work needs the live MCP/event-loop path to
// deliver a wake message to its owner. Fail closed if the board
// cannot be read.
let has_active_board_work = session
.task_board()
.list_tasks(&team_id)
.await
.map(|tasks| {
tasks
.iter()
.any(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::InProgress))
})
.unwrap_or(true);
if has_active_board_work {
debug!(
team_id,
matched_idle_count, "team idle cleanup skipped because task-board work is active"
);
continue;
}

if !agents.iter().all(|agent| {
self.task_manager
.get_task(&agent.conversation_id)
Expand Down Expand Up @@ -1938,7 +1961,6 @@ impl TeamSessionService {
to_slot_id: &str,
content: &str,
) -> Result<AgentMessageQueueResult, TeamError> {
self.require_active_team_run_for_team_work(team_id).await?;
let session = {
let entry = self
.sessions
Expand Down Expand Up @@ -1968,23 +1990,6 @@ impl TeamSessionService {
session.shutdown_agent(caller_slot_id, target_slot_id, reason).await
}

/// Friendly pre-check used before invoking run-scoped team tools. This is
/// not a concurrency guarantee; any operation
/// that writes mailbox, projection, scheduler, spawn, shutdown, or wake state
/// must still acquire a TeamRun operation lease in TeamSession/TeamRunManager.
pub(crate) async fn require_active_team_run_for_team_work(&self, team_id: &str) -> Result<(), TeamError> {
let entry = self
.sessions
.get(team_id)
.ok_or_else(|| TeamError::SessionNotFound(team_id.into()))?;
if entry.session.team_run_manager().current_active_run_id().is_some() {
return Ok(());
}
Err(TeamError::InvalidRequest(
"no active team run for run-scoped wake".into(),
))
}

pub(crate) async fn wake_leader_after_recovery_message(
&self,
team_id: &str,
Expand Down
26 changes: 21 additions & 5 deletions crates/aionui-team/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -610,9 +610,11 @@ impl TeamSession {
slot_id: to_slot_id.to_owned(),
role: target_role_for(to_agent.role),
source: WorkSource::McpSendMessage,
binding: CausalBinding::InheritRunningBatch {
caller_slot_id: from_slot_id.to_owned(),
},
// A teammate message must wake its target even after the previous
// user-visible run has settled. Preserve run causality when one
// exists, otherwise enqueue background work instead of rejecting
// the message for lacking an active run.
binding: CausalBinding::ActiveRunOrBackground,
})?;
let mailbox_message = match self
.mailbox
Expand Down Expand Up @@ -1010,10 +1012,24 @@ impl TeamSession {
if let Some(target) = outcome.cancel_target {
if let Some(turn_id) = target.turn_id {
let agent = self.scheduler.get_agent(slot_id).await?;
self.cancellation_port
// The runtime may already be gone (for example after an ACP
// process crash). Pausing must still terminalize the work
// batch in that case; otherwise the TeamRun retains a stale
// active turn forever and blocks every queued user message.
if let Err(error) = self
.cancellation_port
.cancel_agent_turn(&self.user_id, &agent.conversation_id, &turn_id)
.await
.map_err(|error| TeamError::InvalidRequest(error.to_string()))?;
{
warn!(
team_id = %self.team.id,
team_run_id,
slot_id,
turn_id,
error = %error,
"team slot pause could not cancel an already-unavailable runtime; reclaiming work batch"
);
}
self.team_event_emitter().broadcast_child_turn(
TEAM_CHILD_TURN_CANCELLED_EVENT,
TeamChildTurnPayload {
Expand Down