diff --git a/crates/aionui-team/src/service.rs b/crates/aionui-team/src/service.rs index d96522258..1e315cdd6 100644 --- a/crates/aionui-team/src/service.rs +++ b/crates/aionui-team/src/service.rs @@ -1952,7 +1952,6 @@ impl TeamSessionService { content: &str, files: Option>, ) -> Result { - self.require_active_team_run_for_team_work(team_id).await?; let session = { let entry = self .sessions @@ -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, diff --git a/crates/aionui-team/src/session.rs b/crates/aionui-team/src/session.rs index 9f0bb5a6d..25b66d0d4 100644 --- a/crates/aionui-team/src/session.rs +++ b/crates/aionui-team/src/session.rs @@ -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(); } @@ -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 = Arc::new(MockTeamRepo::new()); @@ -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(); diff --git a/crates/aionui-team/src/team_run/tests.rs b/crates/aionui-team/src/team_run/tests.rs index a819ee2de..d500b0489 100644 --- a/crates/aionui-team/src/team_run/tests.rs +++ b/crates/aionui-team/src/team_run/tests.rs @@ -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"); @@ -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(); diff --git a/crates/aionui-team/src/work_coordinator/coordinator.rs b/crates/aionui-team/src/work_coordinator/coordinator.rs index ae1cb109d..de73bf891 100644 --- a/crates/aionui-team/src/work_coordinator/coordinator.rs +++ b/crates/aionui-team/src/work_coordinator/coordinator.rs @@ -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) } diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index 579a16fb8..0a1d0038c 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -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( @@ -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 @@ -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]