fix(custom-cli): le bouton Stop annule le tour courant sans fermer la session et cascade l'annulation aux agents délégués — stop_agent suit l'arbre des dépendances actives (cancel_current_turn + préemption runtime + enfants d'exécution), TurnCancel tue le process du tour en cours, autorisation requester/sous-arbre sur idea_stop_agent, fixtures DTO runtime_state/node_id alignées — #257 (QA verte)
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
//! that turns a request into the *same* calls the UI makes:
|
||||
//!
|
||||
//! - `spawn_agent` → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`],
|
||||
//! - `stop_agent` → stop the agent's live PTY or structured runtime,
|
||||
//! - `stop_agent` → stop the agent's live PTY or cancel its structured turn,
|
||||
//! - `update_agent_context` → [`UpdateAgentContext`].
|
||||
//!
|
||||
//! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]):
|
||||
@ -1224,7 +1224,9 @@ impl OrchestratorService {
|
||||
result,
|
||||
} => self.reply(project, from, ticket, result),
|
||||
OrchestratorCommand::ListAgents => self.list_agents(project).await,
|
||||
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
|
||||
OrchestratorCommand::StopAgent { name, requester } => {
|
||||
self.stop_agent(project, name, requester).await
|
||||
}
|
||||
OrchestratorCommand::UpdateAgentContext { name, context } => {
|
||||
self.update_agent_context(project, name, context).await
|
||||
}
|
||||
@ -2713,23 +2715,65 @@ impl OrchestratorService {
|
||||
})
|
||||
}
|
||||
|
||||
/// `stop_agent`: translate the agent name → its live runtime → stop it.
|
||||
/// `stop_agent`: translate the agent name → its live runtime → stop/cancel it.
|
||||
async fn stop_agent(
|
||||
&self,
|
||||
project: &Project,
|
||||
name: String,
|
||||
requester: Option<AgentId>,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let agent_id = self
|
||||
.find_agent_id_by_name(project, &name)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;
|
||||
|
||||
self.stop_agent_execution_children(project, agent_id).await;
|
||||
if let Some(requester) = requester {
|
||||
let allowed = requester == agent_id
|
||||
|| self
|
||||
.active_wait_dependencies(requester)
|
||||
.into_iter()
|
||||
.any(|dep| dep == agent_id);
|
||||
if !allowed {
|
||||
return Err(AppError::Invalid(format!(
|
||||
"agent {requester} cannot stop agent {agent_id} outside its active subtree"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let structured = self
|
||||
.structured
|
||||
.clone()
|
||||
.unwrap_or_else(|| Arc::new(StructuredSessions::new()));
|
||||
let dependencies = self.active_wait_dependencies(agent_id);
|
||||
for dep in &dependencies {
|
||||
self.stop_agent_execution_children(project, *dep).await;
|
||||
if let Some(input) = &self.input {
|
||||
input.preempt(runtime_key(project, *dep));
|
||||
}
|
||||
if let Some(session) = structured.session_for_agent_in_project(project.id, dep) {
|
||||
session
|
||||
.cancel_current_turn()
|
||||
.await
|
||||
.map_err(|err| AppError::Process(err.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
self.stop_agent_execution_children(project, agent_id).await;
|
||||
if let Some(input) = &self.input {
|
||||
input.preempt(runtime_key(project, agent_id));
|
||||
}
|
||||
|
||||
if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) {
|
||||
session
|
||||
.cancel_current_turn()
|
||||
.await
|
||||
.map_err(|err| AppError::Process(err.to_string()))?;
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("cancelled current work for agent {name}"),
|
||||
reply: None,
|
||||
});
|
||||
}
|
||||
|
||||
let live = LiveSessions::new(Arc::clone(&self.sessions), structured);
|
||||
if stop_live_agent_runtime_in_project(project, agent_id, &live, &self.close_terminal)
|
||||
.await?
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
//! Companion to the read-only work-state model: these use cases let the UI drive
|
||||
//! an **already-running** agent's live session by **agent id**, never by spawning a
|
||||
//! new process. [`AttachLiveAgent`] rebinds the hosting cell ("a cell is a view");
|
||||
//! [`StopLiveAgent`] tears the live session down polymorphically (PTY kill vs
|
||||
//! structured shutdown). Neither touches the agent entity, its tickets, the
|
||||
//! [`StopLiveAgent`] tears a PTY live session down or cancels the current structured
|
||||
//! turn while preserving the structured session. Neither touches the agent entity, its tickets, the
|
||||
//! conversation summaries, the handoff or the provider sessions.
|
||||
|
||||
use std::sync::Arc;
|
||||
@ -136,14 +136,13 @@ pub trait StopAgentExecutionCascade: Send + Sync {
|
||||
async fn stop_agent_execution_children(&self, project: &Project, agent_id: AgentId);
|
||||
}
|
||||
|
||||
/// Stops an already-running agent's live session, polymorphically.
|
||||
/// Stops an already-running agent's current work.
|
||||
///
|
||||
/// Resolves the session by agent (PTY first, then structured). A PTY session is
|
||||
/// torn down through the existing [`CloseTerminal`] primitive (registry removal +
|
||||
/// process kill); a structured session is removed then `shutdown()`. The agent
|
||||
/// entity, its tickets, conversation summaries and handoff are **not** removed. Any
|
||||
/// exit event is left to the existing infrastructure (the PTY pump / session
|
||||
/// backend) — no bus is invented here.
|
||||
/// Resolves the session by agent (structured first, then PTY). A structured
|
||||
/// session keeps its registry entry and receives `cancel_current_turn()`, so the
|
||||
/// provider conversation/context survives the stop. A PTY session has no finer
|
||||
/// turn boundary, so it is torn down through the existing [`CloseTerminal`]
|
||||
/// primitive (registry removal + process kill).
|
||||
pub struct StopLiveAgent {
|
||||
live: Arc<LiveSessions>,
|
||||
close: Arc<CloseTerminal>,
|
||||
@ -188,7 +187,7 @@ impl StopLiveAgent {
|
||||
self
|
||||
}
|
||||
|
||||
/// Tears down the agent's live session.
|
||||
/// Stops the agent's current work.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::NotFound`] when the agent has no live session,
|
||||
@ -197,22 +196,33 @@ impl StopLiveAgent {
|
||||
&self,
|
||||
input: StopLiveAgentInput,
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
self.stop_dependencies(&input.project, input.agent_id).await;
|
||||
self.stop_dependencies(&input.project, input.agent_id)
|
||||
.await?;
|
||||
if let Some(mediator) = &self.input {
|
||||
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
|
||||
}
|
||||
if let Some(out) = self
|
||||
.cancel_structured_turn_in_project(&input.project, input.agent_id)
|
||||
.await?
|
||||
{
|
||||
return Ok(out);
|
||||
}
|
||||
self.stop_one_in_project(&input.project, input.agent_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stop_dependencies(&self, project: &Project, agent_id: AgentId) {
|
||||
async fn stop_dependencies(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<(), AppError> {
|
||||
if let Some(cascade) = &self.cascade {
|
||||
cascade
|
||||
.stop_agent_execution_children(project, agent_id)
|
||||
.await;
|
||||
}
|
||||
let Some(waits) = &self.waits else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
for dep in waits.active_wait_dependencies(agent_id) {
|
||||
if let Some(cascade) = &self.cascade {
|
||||
@ -221,8 +231,52 @@ impl StopLiveAgent {
|
||||
if let Some(mediator) = &self.input {
|
||||
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
|
||||
}
|
||||
let _ = self.stop_one_in_project(project, dep).await;
|
||||
if self
|
||||
.cancel_structured_turn_in_project(project, dep)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
let _ = self.stop_one_in_project(project, dep).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cancel_structured_turn_in_project(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<Option<StopLiveAgentOutput>, AppError> {
|
||||
let Some(snapshot) = self
|
||||
.live
|
||||
.live_agent_snapshots_for(project.id, &agent_id)
|
||||
.into_iter()
|
||||
.find(|snapshot| snapshot.kind == LiveSessionKind::Structured)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(session) = self
|
||||
.live
|
||||
.structured
|
||||
.session_for_agent_in_project(project.id, &agent_id)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.live
|
||||
.mark_runtime_state(snapshot, LiveRuntimeState::Stopping);
|
||||
session
|
||||
.cancel_current_turn()
|
||||
.await
|
||||
.map_err(|err| AppError::Process(err.to_string()))?;
|
||||
self.live
|
||||
.mark_runtime_state(snapshot, LiveRuntimeState::Stopped);
|
||||
Ok(Some(StopLiveAgentOutput {
|
||||
agent_id: snapshot.agent_id,
|
||||
session_id: snapshot.session_id,
|
||||
node_id: snapshot.node_id,
|
||||
kind: snapshot.kind,
|
||||
runtime_state: LiveRuntimeState::Stopped,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn stop_one_in_project(
|
||||
|
||||
@ -1034,14 +1034,18 @@ async fn stop_agent_kills_the_right_session() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_stops_structured_session() {
|
||||
async fn stop_agent_cancels_structured_turn_without_closing_session() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||
let fx = ask_fixture(contexts);
|
||||
let cancel_called = Arc::new(Mutex::new(0_u32));
|
||||
let shutdown_called = Arc::new(Mutex::new(0_u32));
|
||||
let session = Arc::new(CompletionSession {
|
||||
id: sid(9010),
|
||||
agent: agent.id,
|
||||
completions: fx.mailbox.completions(),
|
||||
cancel_called: Some(Arc::clone(&cancel_called)),
|
||||
shutdown_called: Some(Arc::clone(&shutdown_called)),
|
||||
}) as Arc<dyn AgentSession>;
|
||||
fx.structured
|
||||
.insert_in_project(project_id(), session, agent.id, nid(9010));
|
||||
@ -1062,10 +1066,60 @@ async fn stop_agent_stops_structured_session() {
|
||||
fx.pty.kills().is_empty(),
|
||||
"structured stop must not kill PTY"
|
||||
);
|
||||
assert_eq!(*cancel_called.lock().unwrap(), 1);
|
||||
assert_eq!(
|
||||
*shutdown_called.lock().unwrap(),
|
||||
0,
|
||||
"structured stop must not shutdown the session"
|
||||
);
|
||||
assert!(
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &agent.id)
|
||||
.is_some(),
|
||||
"structured session is preserved for later turns"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_agent_from_unrelated_agent_is_rejected() {
|
||||
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
||||
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||
let fx = ask_fixture(contexts);
|
||||
let cancel_called = Arc::new(Mutex::new(0_u32));
|
||||
let shutdown_called = Arc::new(Mutex::new(0_u32));
|
||||
let session = Arc::new(CompletionSession {
|
||||
id: sid(9010),
|
||||
agent: agent.id,
|
||||
completions: fx.mailbox.completions(),
|
||||
cancel_called: Some(Arc::clone(&cancel_called)),
|
||||
shutdown_called: Some(Arc::clone(&shutdown_called)),
|
||||
}) as Arc<dyn AgentSession>;
|
||||
fx.structured
|
||||
.insert_in_project(project_id(), session, agent.id, nid(9010));
|
||||
|
||||
let requester = uuid::Uuid::from_u128(99);
|
||||
let err = fx
|
||||
.service
|
||||
.dispatch(
|
||||
&project(),
|
||||
cmd(&format!(
|
||||
r#"{{ "type":"agent.stop", "requestedBy":"{requester}", "targetAgent":"dev-backend" }}"#
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
assert_eq!(
|
||||
*cancel_called.lock().unwrap(),
|
||||
0,
|
||||
"unrelated agent must not cancel target work"
|
||||
);
|
||||
assert_eq!(*shutdown_called.lock().unwrap(), 0);
|
||||
assert!(fx
|
||||
.structured
|
||||
.session_for_agent_in_project(project_id(), &agent.id)
|
||||
.is_none());
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1162,6 +1216,8 @@ async fn ask_agent_after_delete_recreate_same_name_uses_new_agent_id() {
|
||||
id: sid(9001),
|
||||
agent: old.id,
|
||||
completions: fx.mailbox.completions(),
|
||||
cancel_called: None,
|
||||
shutdown_called: None,
|
||||
}) as Arc<dyn AgentSession>;
|
||||
fx.structured
|
||||
.insert_in_project(project_id(), old_session, old.id, nid(9001));
|
||||
@ -1617,6 +1673,8 @@ struct CompletionSession {
|
||||
id: SessionId,
|
||||
agent: AgentId,
|
||||
completions: Arc<CompletionBus>,
|
||||
cancel_called: Option<Arc<Mutex<u32>>>,
|
||||
shutdown_called: Option<Arc<Mutex<u32>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -1643,7 +1701,16 @@ impl AgentSession for CompletionSession {
|
||||
)),
|
||||
}
|
||||
}
|
||||
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
|
||||
if let Some(cancel_called) = &self.cancel_called {
|
||||
*cancel_called.lock().unwrap() += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
if let Some(shutdown_called) = &self.shutdown_called {
|
||||
*shutdown_called.lock().unwrap() += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@ -1701,6 +1768,8 @@ impl AgentSessionFactory for CompletionFactory {
|
||||
id,
|
||||
agent,
|
||||
completions: Arc::clone(&self.completions),
|
||||
cancel_called: None,
|
||||
shutdown_called: None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@ -1728,6 +1797,8 @@ fn seed_structured_sessions(
|
||||
id: SessionId::from_uuid(Uuid::from_u128(8_000 + idx as u128)),
|
||||
agent: entry.agent_id,
|
||||
completions: Arc::clone(&completions),
|
||||
cancel_called: None,
|
||||
shutdown_called: None,
|
||||
}) as Arc<dyn AgentSession>;
|
||||
structured.insert(session, entry.agent_id, nid(8_000 + idx as u128));
|
||||
}
|
||||
|
||||
@ -98,10 +98,11 @@ impl PtyPort for FakePty {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake structured [`AgentSession`] recording whether `shutdown` was called.
|
||||
/// A fake structured [`AgentSession`] recording stop/cancel calls.
|
||||
struct FakeSession {
|
||||
id: SessionId,
|
||||
shutdown_called: Arc<AtomicBool>,
|
||||
cancel_called: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -115,6 +116,10 @@ impl AgentSession for FakeSession {
|
||||
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||
Ok(Box::new(std::iter::empty()))
|
||||
}
|
||||
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
|
||||
self.cancel_called.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
||||
self.shutdown_called.store(true, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@ -187,18 +192,20 @@ fn insert_structured(
|
||||
session_id: SessionId,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
) -> (Arc<AtomicBool>, Arc<AtomicBool>) {
|
||||
let shutdown_called = Arc::new(AtomicBool::new(false));
|
||||
let cancel_called = Arc::new(AtomicBool::new(false));
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
Arc::new(FakeSession {
|
||||
id: session_id,
|
||||
shutdown_called: Arc::clone(&flag),
|
||||
shutdown_called: Arc::clone(&shutdown_called),
|
||||
cancel_called: Arc::clone(&cancel_called),
|
||||
}),
|
||||
agent_id,
|
||||
node_id,
|
||||
);
|
||||
flag
|
||||
(shutdown_called, cancel_called)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -239,7 +246,7 @@ fn attach_pty_rebinds_node_without_changing_session() {
|
||||
fn attach_structured_rebinds_node() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
let _flag = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
let _flags = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.attach
|
||||
@ -339,10 +346,10 @@ async fn stop_pty_kills_and_removes_session() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_structured_shuts_down_and_removes_session() {
|
||||
async fn stop_structured_cancels_turn_and_keeps_session() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
|
||||
let out = f
|
||||
.stop
|
||||
@ -356,12 +363,19 @@ async fn stop_structured_shuts_down_and_removes_session() {
|
||||
assert_eq!(out.session_id, sid(2));
|
||||
assert_eq!(out.kind, LiveSessionKind::Structured);
|
||||
assert_eq!(out.runtime_state, LiveRuntimeState::Stopped);
|
||||
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
|
||||
assert!(f.structured.is_empty(), "live session removed");
|
||||
assert!(
|
||||
cancel_called.load(Ordering::SeqCst),
|
||||
"session.cancel_current_turn() was called"
|
||||
);
|
||||
assert!(
|
||||
!shutdown_called.load(Ordering::SeqCst),
|
||||
"session.shutdown() must not be called"
|
||||
);
|
||||
assert!(!f.structured.is_empty(), "live session is preserved");
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.session_id_for_agent_in_project(project().id, &a),
|
||||
None
|
||||
Some(sid(2))
|
||||
);
|
||||
// No PTY was touched.
|
||||
assert!(f.pty_port.kills().is_empty());
|
||||
@ -371,7 +385,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
|
||||
async fn stop_structured_runs_execution_cascade_best_effort() {
|
||||
let f = fixture();
|
||||
let a = aid(10);
|
||||
let flag = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
let (shutdown_called, cancel_called) = insert_structured(&f.structured, sid(2), a, nid(100));
|
||||
let cascade = Arc::new(FakeCascade::default());
|
||||
let stop = f
|
||||
.stop
|
||||
@ -387,8 +401,15 @@ async fn stop_structured_runs_execution_cascade_best_effort() {
|
||||
|
||||
assert_eq!(out.session_id, sid(2));
|
||||
assert_eq!(*cascade.calls.lock().unwrap(), vec![(project().id, a)]);
|
||||
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
|
||||
assert!(f.structured.is_empty(), "live session removed");
|
||||
assert!(
|
||||
cancel_called.load(Ordering::SeqCst),
|
||||
"session.cancel_current_turn() was called"
|
||||
);
|
||||
assert!(
|
||||
!shutdown_called.load(Ordering::SeqCst),
|
||||
"session.shutdown() must not be called"
|
||||
);
|
||||
assert!(!f.structured.is_empty(), "live session is preserved");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user