Skip to content
Merged
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
18 changes: 0 additions & 18 deletions crates/aionui-team/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,6 @@ impl TeamSessionService {
content: &str,
files: Option<Vec<String>>,
) -> Result<AgentMessageQueueResult, TeamError> {
self.require_active_team_run_for_team_work(team_id).await?;
let session = {
let entry = self
.sessions
Expand Down Expand Up @@ -1982,23 +1981,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
27 changes: 22 additions & 5 deletions crates/aionui-team/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2292,17 +2292,26 @@ mod tests {
}

#[tokio::test]
async fn shutdown_without_active_run_queues_background_work() {
async fn shutdown_without_active_run_opens_system_lifecycle_run() {
let session = start_session().await;

session
.shutdown_agent("lead-1", "worker-1", Some("done".into()))
.await
.expect("background caller may queue shutdown work");
.expect("leader may shut down a teammate");

let unread = session.mailbox().peek_unread("t1", "worker-1").await.unwrap();
assert_eq!(unread.len(), 1);
assert!(session.team_run_manager().current_active_run_id().is_none());
// A run-scoped shutdown wake with no inheritable run now converges into a
// SystemLifecycle run at the enqueue choke-point instead of enqueuing
// run-less background work.
assert!(session.team_run_manager().current_active_run_id().is_some());
let payload = session
.team_run_manager()
.current_payload(&session.work_coordinator.snapshot())
.expect("shutdown wake must open a run payload");
assert_eq!(payload.source, aionui_api_types::TeamRunSource::SystemLifecycle);
assert!(!payload.has_user_intervention);
session.stop();
}

Expand Down Expand Up @@ -2711,7 +2720,7 @@ mod tests {
}

#[tokio::test]
async fn background_turn_is_reported_running_without_a_team_run() {
async fn shutdown_turn_is_reported_running_inside_a_system_run() {
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let repo: Arc<dyn ITeamRepository> = Arc::new(MockTeamRepo::new());
Expand Down Expand Up @@ -2745,7 +2754,15 @@ mod tests {
let slot = session.work_coordinator.slot_snapshot("worker-1").unwrap();
assert_eq!(slot.state, SlotPhase::Running);
assert_eq!(slot.active_turn_id.as_deref(), Some("turn-background"));
assert!(session.team_run_manager.current_active_run_id().is_none());
// The shutdown wake now converges into a SystemLifecycle run at the
// enqueue choke-point, so the reported-running turn lives inside a run
// rather than being run-less.
assert!(session.team_run_manager.current_active_run_id().is_some());
let payload = session
.team_run_manager
.current_payload(&session.work_coordinator.snapshot())
.expect("shutdown wake must open a run payload");
assert_eq!(payload.source, aionui_api_types::TeamRunSource::SystemLifecycle);

release_tx.send(()).unwrap();
session.stop();
Expand Down
39 changes: 38 additions & 1 deletion crates/aionui-team/src/team_run/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ fn mcp_message_inherits_the_callers_running_batch_causality() {
},
})
.unwrap();
assert_eq!(background_child.team_run_id, None);
// Post-fix: an InheritRunningBatch wake with no inheritable run no longer
// goes run-less; bind_system_enqueue attaches it to the active run.
assert_eq!(background_child.team_run_id, unrelated_user.team_run_id);

coordinator.abort_enqueue(&background_child, "test_complete");
coordinator.abort_enqueue(&unrelated_user, "test_complete");
Expand Down Expand Up @@ -331,6 +333,41 @@ fn system_initiated_without_active_run_opens_system_lifecycle_run() {
);
}

#[test]
fn inherit_running_batch_without_active_run_opens_system_lifecycle_run() {
// A run-scoped wake (team_send_message / team_shutdown_agent both build
// InheritRunningBatch) issued while the caller has no inheritable active
// run must now open a SystemLifecycle run instead of going run-less.
let (coordinator, manager) = coordinator_and_manager();
coordinator.set_runtime_constraint("worker-1", RuntimeConstraint::Ready);

// "lead-1" has no active batch/run: nothing to inherit.
let lease = coordinator
.acquire_enqueue(EnqueueRequest {
slot_id: "worker-1".into(),
role: TeamRunTargetRole::Teammate,
source: WorkSource::McpSendMessage,
binding: CausalBinding::InheritRunningBatch {
caller_slot_id: "lead-1".into(),
},
})
.unwrap();
assert!(
lease.team_run_id.is_some(),
"run-scoped wake with no inheritable run must ensure a run"
);

coordinator.commit_enqueue(&lease, Some("m1".into())).unwrap();
let ReconcileDecision::Claim(_batch) = coordinator.next("worker-1") else {
panic!("wake intent must be claimable");
};

assert!(manager.current_active_run_id().is_some());
let payload = manager.current_payload(&coordinator.snapshot()).unwrap();
assert_eq!(payload.source, TeamRunSource::SystemLifecycle);
assert!(!payload.has_user_intervention);
}

#[test]
fn system_initiated_attaches_to_existing_user_run() {
let (coordinator, manager) = coordinator_and_manager();
Expand Down
24 changes: 18 additions & 6 deletions crates/aionui-team/src/work_coordinator/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,27 @@ impl SlotWorkCoordinator {
created_new_run: false,
user_intervention: false,
},
CausalBinding::InheritRunningBatch { caller_slot_id } => RunBinding {
team_run_id: state
CausalBinding::InheritRunningBatch { caller_slot_id } => {
let inherited = state
.slots
.get(caller_slot_id)
.and_then(|caller| caller.active.as_ref())
.and_then(|active| active.batch.team_run_ids.first().cloned()),
created_new_run: false,
user_intervention: false,
},
.and_then(|active| active.batch.team_run_ids.first().cloned());
match inherited {
// Inherit the caller's active run when there is one.
Some(team_run_id) => RunBinding {
team_run_id: Some(team_run_id),
created_new_run: false,
user_intervention: false,
},
// No inheritable run: a run-scoped wake (send/shutdown) is a
// legitimate work initiation and must live in a run, so fall
// back to attach-or-create a SystemLifecycle run (same as
// SystemInitiated). Closes "no active team run for
// run-scoped wake" at the single enqueue choke-point.
None => self.run_causality.bind_system_enqueue(&request),
}
}
CausalBinding::UserVisible | CausalBinding::ActiveRunOrBackground => {
self.run_causality.bind_enqueue(&request)
}
Expand Down
65 changes: 60 additions & 5 deletions crates/aionui-team/tests/session_service_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5065,7 +5065,7 @@ async fn leader_spawn_then_immediate_ensure_joins_the_same_attach_operation() {
}

#[tokio::test]
async fn lead_send_agent_message_in_session_requires_active_team_run() {
async fn lead_send_agent_message_without_active_run_opens_system_lifecycle_run() {
let svc = setup();
let created = svc
.create_team(
Expand All @@ -5079,9 +5079,11 @@ async fn lead_send_agent_message_in_session_requires_active_team_run() {
.await
.expect("create team");

// Session loaded with NO active team run: this is the run-less window in
// which the leader autonomously wakes a teammate.
svc.ensure_session("user1", &created.id)
.await
.expect("session should be loaded without active Team Run");
.expect("session should load without an active team run");

let lead_slot_id = created
.leader_assistant_id
Expand All @@ -5094,11 +5096,64 @@ async fn lead_send_agent_message_in_session_requires_active_team_run() {
.map(|agent| agent.slot_id.clone())
.expect("seeded teammate slot");

let err = svc
// Pre-fix: this returned Err("no active team run for run-scoped wake").
// Post-fix: the wake is a legitimate work initiation and succeeds.
let result = svc
.send_agent_message_from_agent(&created.id, &lead_slot_id, &worker_slot_id, "Do this", None)
.await
.expect_err("leader direct message should require active Team Run");
assert!(err.to_string().contains("no active team run"));
.expect("run-less run-scoped wake must now succeed");
assert!(result.team_run_id.is_some(), "the wake must land inside a team run");

let run_state = svc.get_run_state("user1", &created.id).await.unwrap();
let active_run = run_state
.active_run
.expect("run-scoped wake must open/attach an active team run");
assert_eq!(active_run.source, TeamRunSource::SystemLifecycle);
assert!(!active_run.has_user_intervention);
}

#[tokio::test]
async fn lead_shutdown_agent_without_active_run_opens_system_lifecycle_run() {
let svc = setup();
let created = svc
.create_team(
"user1",
CreateTeamRequest {
name: "Alpha".into(),
agents: two_agent_input(),
workspace: None,
},
)
.await
.expect("create team");
svc.ensure_session("user1", &created.id)
.await
.expect("session should load without an active team run");

let lead_slot_id = created
.leader_assistant_id
.clone()
.expect("created team should have a lead slot");
let worker_slot_id = created
.assistants
.iter()
.find(|agent| agent.role == "teammate")
.map(|agent| agent.slot_id.clone())
.expect("seeded teammate slot");

// team_shutdown_agent shares the same InheritRunningBatch binding as send
// and is NOT gated by the (now-deleted) guard. Pre-fix it enqueued run-less
// ("background") and opened NO run; post-fix it lands in an active run.
svc.shutdown_agent_in_session(&created.id, &lead_slot_id, &worker_slot_id, Some("done".into()))
.await
.expect("leader shutdown of a teammate must succeed");

let run_state = svc.get_run_state("user1", &created.id).await.unwrap();
let active_run = run_state
.active_run
.expect("shutdown wake must open/attach an active team run");
assert_eq!(active_run.source, TeamRunSource::SystemLifecycle);
assert!(!active_run.has_user_intervention);
}

#[tokio::test]
Expand Down
Loading