fix(domain,application,infrastructure,backend,app-tauri,frontend): Stop demande agent préserve la conversation ouverte — arrêt sans fermeture/clôture du chat, états cohérents après cancel sur les 3 adapters de session, cellule et workstate synchronisés — #343 (QA verte)

This commit is contained in:
2026-09-13 10:03:36 +02:00
parent a1e8a6466f
commit c37638ff24
26 changed files with 1210 additions and 232 deletions

View File

@ -263,10 +263,11 @@ pub use template::{
SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput, UpdateTemplateOutput,
};
pub use terminal::{
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, LiveRuntimeState,
LiveRuntimeStatus, LiveSessionKind, LiveSessionSnapshot, LiveSessions, OpenTerminal,
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, StructuredSessions,
TerminalSessions, WriteToTerminal, WriteToTerminalInput,
CancelAgentChat, CancelAgentChatOutput, CloseTerminal, CloseTerminalInput, CloseTerminalOutput,
LiveAgentRegistry, LiveRuntimeState, LiveRuntimeStatus, LiveSessionKind, LiveSessionSnapshot,
LiveSessions, OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal,
ResizeTerminalInput, StructuredSessions, TerminalSessions, WriteToTerminal,
WriteToTerminalInput,
};
pub use ticket_assistant::{
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,

View File

@ -1672,6 +1672,105 @@ impl OrchestratorService {
descendants
}
/// Cancels resources owned by the current turn without closing runtime admission
/// or removing any reusable agent session.
pub async fn cancel_agent_turn_execution_children(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<(), AppError> {
let root = runtime_key(project, agent_id);
let mut keys = vec![root];
let mut participants = HashSet::from([root]);
{
let control = self
.execution_control
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while let Some(parent) = keys.pop() {
for child in control
.launched_by
.iter()
.filter_map(|(child, owner)| (*owner == parent).then_some(*child))
.collect::<Vec<_>>()
{
if participants.insert(child) {
keys.push(child);
}
}
}
}
participants.extend(
self.active_wait_dependencies_from_wait_edges(agent_id)
.into_iter()
.map(|agent| runtime_key(project, agent)),
);
let rendezvous_targets: Vec<_> = self
.active_rendezvous
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter_map(|(target, active)| {
(participants.contains(target)
|| active.link.requester_agent_id.is_some_and(|requester| {
participants
.iter()
.any(|participant| participant.agent_id == requester)
}))
.then_some(*target)
})
.collect();
for target in rendezvous_targets {
self.restart_rendezvous.cancel(target);
}
let mut first_error = None;
for participant in &participants {
self.restart_rendezvous.cancel(*participant);
if let Some(session_limits) = &self.session_limits {
session_limits.cancel_resume(participant.agent_id);
}
if let Some(input) = &self.input {
input.preempt(*participant);
}
if let Some(structured) = &self.structured {
if participant.agent_id != agent_id {
if let Some(session) =
structured.session_for_agent_in_project(project.id, &participant.agent_id)
{
if let Err(error) = session.cancel_current_turn().await {
first_error.get_or_insert_with(|| AppError::from(error));
}
}
}
}
}
let (Some(store), Some(runner), Some(clock)) =
(&self.background_tasks, &self.background_runner, &self.clock)
else {
return first_error.map_or(Ok(()), Err);
};
let mut cancel = crate::background::CancelBackgroundTask::new(
Arc::clone(store),
Arc::clone(runner),
Arc::clone(clock),
);
if let Some(events) = &self.events {
cancel = cancel.with_events(Arc::clone(events));
}
for participant in participants {
if let Err(error) = cancel
.cancel_subtree_for_agent(project.id, participant.agent_id)
.await
{
first_error.get_or_insert(error);
}
}
first_error.map_or(Ok(()), Err)
}
/// Returns the transitive dependencies of `agent` in an active wait-edge snapshot.
#[must_use]
fn active_wait_dependencies_from_edges(

View File

@ -33,7 +33,8 @@ pub use registry::{
LiveSessions, StructuredSessions, TerminalSessions,
};
pub use usecases::{
stop_live_agent_runtime_in_project, CloseTerminal, CloseTerminalInput, CloseTerminalOutput,
OpenTerminal, OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput,
StoppedAgentRuntime, WriteToTerminal, WriteToTerminalInput,
stop_live_agent_runtime_in_project, CancelAgentChat, CancelAgentChatOutput, CloseTerminal,
CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput, OpenTerminalOutput,
ResizeTerminal, ResizeTerminalInput, StoppedAgentRuntime, WriteToTerminal,
WriteToTerminalInput,
};

View File

@ -537,6 +537,17 @@ struct StructuredEntry {
managed_temp_handle: Option<String>,
/// Optional local-model-server usage guard retained for the live session.
_model_server_guard: Option<ModelServerUseGuard>,
/// Monotonic logical-turn generation used to fence late provider events.
turn_generation: u64,
/// Current/last logical-turn state for idempotent request cancellation.
turn_state: StructuredTurnState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StructuredTurnState {
Completed,
Active(u64),
Cancelled(u64),
}
#[derive(Clone)]
@ -660,6 +671,8 @@ impl StructuredSessions {
transcript_restore_expected: false,
managed_temp_handle,
_model_server_guard: model_server_guard,
turn_generation: 0,
turn_state: StructuredTurnState::Completed,
},
);
}
@ -692,6 +705,75 @@ impl StructuredSessions {
})
}
/// Starts a new logical human turn and returns its fencing generation.
pub fn begin_turn(&self, id: &SessionId) -> Option<u64> {
self.entries.lock().ok().and_then(|mut entries| {
let entry = entries.get_mut(id)?;
entry.turn_generation = entry.turn_generation.wrapping_add(1).max(1);
let generation = entry.turn_generation;
entry.turn_state = StructuredTurnState::Active(generation);
Some(generation)
})
}
/// Atomically records a cancellation request and returns the live session.
pub fn request_turn_cancel(
&self,
id: &SessionId,
) -> Option<(
Arc<dyn AgentSession>,
domain::ports::CancelCurrentTurnOutcome,
)> {
self.entries.lock().ok().and_then(|mut entries| {
let entry = entries.get_mut(id)?;
let outcome = match entry.turn_state {
StructuredTurnState::Active(generation) => {
entry.turn_state = StructuredTurnState::Cancelled(generation);
domain::ports::CancelCurrentTurnOutcome::Cancelled
}
StructuredTurnState::Cancelled(_) => {
domain::ports::CancelCurrentTurnOutcome::AlreadyCancelled
}
StructuredTurnState::Completed => {
domain::ports::CancelCurrentTurnOutcome::AlreadyCompleted
}
};
Some((Arc::clone(&entry.session), outcome))
})
}
/// True while `generation` is still the uncancelled current turn.
#[must_use]
pub fn accepts_turn_output(&self, id: &SessionId, generation: u64) -> bool {
self.entries
.lock()
.ok()
.and_then(|entries| entries.get(id).map(|entry| entry.turn_state))
== Some(StructuredTurnState::Active(generation))
}
/// Retires a logical turn without allowing an older generation to affect a newer one.
pub fn finish_turn(&self, id: &SessionId, generation: u64) {
if let Ok(mut entries) = self.entries.lock() {
if let Some(entry) = entries.get_mut(id) {
if entry.turn_state == StructuredTurnState::Active(generation) {
entry.turn_state = StructuredTurnState::Completed;
}
}
}
}
/// Reopens the same generation when its concrete cancellation failed.
pub fn restore_turn_after_cancel_failure(&self, id: &SessionId) {
if let Ok(mut entries) = self.entries.lock() {
if let Some(entry) = entries.get_mut(id) {
if let StructuredTurnState::Cancelled(generation) = entry.turn_state {
entry.turn_state = StructuredTurnState::Active(generation);
}
}
}
}
/// Records whether this runtime was opened from an existing logical conversation.
pub fn set_transcript_restore_expected(&self, id: SessionId, expected: bool) {
if let Ok(mut entries) = self.entries.lock() {

View File

@ -2,7 +2,7 @@
use std::sync::Arc;
use domain::ports::{EventBus, PtyPort, SpawnSpec};
use domain::ports::{CancelCurrentTurnOutcome, EventBus, PtyPort, SpawnSpec};
use domain::{
AgentId, DomainEvent, NodeId, Project, ProjectPath, PtySize, SessionId, SessionKind,
SessionStatus, TerminalSession,
@ -11,7 +11,54 @@ use domain::{
use crate::error::AppError;
use crate::managed_temp::{ReleaseManagedTemp, ReleaseManagedTempInput};
use super::registry::{LiveRuntimeState, LiveSessionKind, LiveSessions, TerminalSessions};
use super::registry::{
LiveRuntimeState, LiveSessionKind, LiveSessions, StructuredSessions, TerminalSessions,
};
/// Result of cancelling the current request while preserving its structured session.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CancelAgentChatOutput {
/// Stable session that remains registered and reusable.
pub session_id: SessionId,
/// Whether this call cancelled work or observed an already-terminal turn.
pub outcome: CancelCurrentTurnOutcome,
}
/// Cancels one structured chat turn without stopping its reusable runtime.
pub struct CancelAgentChat {
sessions: Arc<StructuredSessions>,
}
impl CancelAgentChat {
/// Builds the use case from the structured runtime registry.
#[must_use]
pub fn new(sessions: Arc<StructuredSessions>) -> Self {
Self { sessions }
}
/// Requests cancellation from the live session and deliberately leaves its
/// registry entry, conversation identity and runtime state untouched.
///
/// # Errors
/// Returns [`AppError::NotFound`] only when the session itself is absent, and
/// a process error when the adapter could not interrupt its current child.
pub async fn execute(&self, session_id: SessionId) -> Result<CancelAgentChatOutput, AppError> {
let (session, outcome) = self
.sessions
.request_turn_cancel(&session_id)
.ok_or_else(|| AppError::NotFound(format!("structured session {session_id}")))?;
if outcome == CancelCurrentTurnOutcome::Cancelled {
if let Err(error) = session.cancel_current_turn().await {
self.sessions.restore_turn_after_cancel_failure(&session_id);
return Err(AppError::from(error));
}
}
Ok(CancelAgentChatOutput {
session_id,
outcome,
})
}
}
/// Input for [`OpenTerminal::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
@ -281,8 +328,9 @@ pub struct StoppedAgentRuntime {
/// Stops one agent's live runtime across both registries.
///
/// PTY sessions are closed through [`CloseTerminal`]. Structured sessions are
/// removed from their registry before `shutdown()` so relaunch guards stop seeing
/// them as live even while shutdown runs.
/// removed from their registry, have their current turn cancelled, then receive
/// `shutdown()` so relaunch guards stop seeing them as live during teardown and
/// no per-turn child survives it.
pub async fn stop_live_agent_runtime_in_project(
project: &Project,
agent_id: AgentId,
@ -298,6 +346,9 @@ pub async fn stop_live_agent_runtime_in_project(
let removed = match snapshot.kind {
LiveSessionKind::Structured => {
if let Some(removed) = live.structured.remove_runtime(&snapshot.session_id) {
if let Err(error) = removed.session.cancel_current_turn().await {
first_error.get_or_insert_with(|| AppError::Process(error.to_string()));
}
if let Err(error) = removed.session.shutdown().await {
first_error.get_or_insert_with(|| AppError::Process(error.to_string()));
}

View File

@ -245,13 +245,12 @@ pub trait StopAgentExecutionCascade: Send + Sync {
) -> Result<(), AppError>;
}
/// Stops an already-running agent's current work.
/// Destructively stops an already-running agent runtime.
///
/// 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).
/// Both structured and PTY runtimes are removed. Structured sessions first
/// receive `cancel_current_turn()` so a per-turn child cannot survive teardown,
/// then `shutdown()` is called. Conversation-level cancellation uses the
/// separate [`crate::CancelAgentChat`] use case and never enters this path.
pub struct StopAgentRuntime {
live: Arc<LiveSessions>,
close: Arc<CloseTerminal>,
@ -317,7 +316,7 @@ impl StopAgentRuntime {
self
}
/// Stops the agent's current work.
/// Stops the agent's complete live runtime.
///
/// # Errors
/// Repeated stops are successful. A cancellation or runtime failure is returned

View File

@ -0,0 +1,142 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use application::{
CancelAgentChat, LiveRuntimeState, LiveSessions, StructuredSessions, TerminalSessions,
};
use async_trait::async_trait;
use domain::ports::{AgentSession, AgentSessionError, CancelCurrentTurnOutcome, ReplyStream};
use domain::{AgentId, NodeId, ProjectId, SessionId};
use uuid::Uuid;
struct FakeSession {
id: SessionId,
cancellations: Mutex<VecDeque<CancelCurrentTurnOutcome>>,
sends: Mutex<Vec<String>>,
shutdowns: Mutex<usize>,
}
impl FakeSession {
fn new(id: SessionId, outcomes: impl IntoIterator<Item = CancelCurrentTurnOutcome>) -> Self {
Self {
id,
cancellations: Mutex::new(outcomes.into_iter().collect()),
sends: Mutex::new(Vec::new()),
shutdowns: Mutex::new(0),
}
}
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
Some("conversation-kept".to_owned())
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.sends.lock().unwrap().push(prompt.to_owned());
Ok(Box::new(std::iter::empty()))
}
async fn cancel_current_turn(&self) -> Result<CancelCurrentTurnOutcome, AgentSessionError> {
Ok(self
.cancellations
.lock()
.unwrap()
.pop_front()
.unwrap_or(CancelCurrentTurnOutcome::AlreadyCompleted))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
*self.shutdowns.lock().unwrap() += 1;
Ok(())
}
}
fn id<T>(value: u128, make: impl FnOnce(Uuid) -> T) -> T {
make(Uuid::from_u128(value))
}
#[tokio::test]
async fn cancel_keeps_session_and_accepts_a_second_prompt_without_shutdown() {
let session_id = id(1, SessionId::from_uuid);
let project_id = id(2, ProjectId::from_uuid);
let agent_id = id(3, AgentId::from_uuid);
let node_id = id(4, NodeId::from_uuid);
let session = Arc::new(FakeSession::new(
session_id,
[CancelCurrentTurnOutcome::Cancelled],
));
let sessions = Arc::new(StructuredSessions::new());
sessions.insert_in_project(project_id, session.clone(), agent_id, node_id);
let live = LiveSessions::new(Arc::new(TerminalSessions::new()), Arc::clone(&sessions));
let cancel = CancelAgentChat::new(Arc::clone(&sessions));
let first_generation = sessions.begin_turn(&session_id).unwrap();
assert!(sessions.accepts_turn_output(&session_id, first_generation));
let output = cancel.execute(session_id).await.unwrap();
assert_eq!(output.outcome, CancelCurrentTurnOutcome::Cancelled);
assert!(!sessions.accepts_turn_output(&session_id, first_generation));
let retained = sessions.session(&session_id).expect("session is retained");
let second_generation = sessions.begin_turn(&session_id).unwrap();
assert!(sessions.accepts_turn_output(&session_id, second_generation));
sessions.finish_turn(&session_id, first_generation);
assert!(
sessions.accepts_turn_output(&session_id, second_generation),
"an old turn cannot retire or fence its successor"
);
let _second_reply = retained.send("second prompt").await.unwrap();
assert_eq!(*session.sends.lock().unwrap(), ["second prompt"]);
assert_eq!(*session.shutdowns.lock().unwrap(), 0);
assert_eq!(
live.runtime_status_for(project_id, &agent_id)
.unwrap()
.state,
LiveRuntimeState::Running
);
assert_eq!(
sessions.meta_for_session(&session_id).unwrap().3.as_deref(),
Some("conversation-kept")
);
}
#[tokio::test]
async fn double_cancel_is_typed_and_missing_session_remains_distinct() {
let session_id = id(10, SessionId::from_uuid);
let session = Arc::new(FakeSession::new(
session_id,
[
CancelCurrentTurnOutcome::Cancelled,
CancelCurrentTurnOutcome::AlreadyCancelled,
],
));
let sessions = Arc::new(StructuredSessions::new());
sessions.insert_in_project(
id(11, ProjectId::from_uuid),
session,
id(12, AgentId::from_uuid),
id(13, NodeId::from_uuid),
);
let cancel = CancelAgentChat::new(Arc::clone(&sessions));
sessions.begin_turn(&session_id).unwrap();
assert_eq!(
cancel.execute(session_id).await.unwrap().outcome,
CancelCurrentTurnOutcome::Cancelled
);
assert_eq!(
cancel.execute(session_id).await.unwrap().outcome,
CancelCurrentTurnOutcome::AlreadyCancelled
);
let error = cancel
.execute(id(99, SessionId::from_uuid))
.await
.unwrap_err();
assert_eq!(error.code(), "NOT_FOUND");
assert!(sessions.session(&session_id).is_some());
}

View File

@ -44,10 +44,10 @@ use domain::{Memory, MemoryIndexEntry, MemoryLink, MemorySlug};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean, HarvestMemoryFromTurn,
LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider, OrchestratorService,
SessionLimitService, SpawnBackgroundCommand, TerminalSessions, UpdateAgentContext,
UpdateLiveState,
CancelAgentChat, CloseTerminal, CreateAgentFromScratch, CreateSkill, GetLiveStateLean,
HarvestMemoryFromTurn, LaunchAgent, ListAgents, LiveStateProvider, LiveStateReadProvider,
OrchestratorService, SessionLimitService, SpawnBackgroundCommand, TerminalSessions,
UpdateAgentContext, UpdateLiveState,
};
use domain::live_state::{LiveEntry, LiveState, WorkStatus};
use domain::ports::LiveStateStore;
@ -1428,7 +1428,11 @@ async fn stop_agent_closes_structured_runtime_and_is_idempotent() {
fx.pty.kills().is_empty(),
"structured stop must not kill PTY"
);
assert_eq!(*cancel_called.lock().unwrap(), 0);
assert_eq!(
*cancel_called.lock().unwrap(),
1,
"structured hard stop must cancel the live turn before shutdown"
);
assert_eq!(
*shutdown_called.lock().unwrap(),
1,
@ -1453,6 +1457,46 @@ async fn stop_agent_closes_structured_runtime_and_is_idempotent() {
assert_eq!(*shutdown_called.lock().unwrap(), 1);
}
#[tokio::test]
async fn cancel_chat_keeps_structured_runtime_and_execution_admission_open() {
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_id = sid(9343);
let session = Arc::new(CompletionSession {
id: session_id,
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(9343));
fx.structured.begin_turn(&session_id).unwrap();
let output = CancelAgentChat::new(Arc::clone(&fx.structured))
.execute(session_id)
.await
.expect("turn cancel succeeds");
fx.service
.cancel_agent_turn_execution_children(&project(), agent.id)
.await
.expect("child cancellation succeeds");
assert_eq!(
output.outcome,
domain::ports::CancelCurrentTurnOutcome::Cancelled
);
assert_eq!(*cancel_called.lock().unwrap(), 1);
assert_eq!(*shutdown_called.lock().unwrap(), 0);
assert!(fx.structured.session(&session_id).is_some());
fx.service
.ensure_agent_execution_active(&project(), agent.id)
.expect("turn cancellation must not close #320 admission");
}
#[tokio::test]
async fn stop_agent_from_unrelated_agent_is_rejected() {
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
@ -2117,11 +2161,13 @@ impl AgentSession for CompletionSession {
)),
}
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
async fn cancel_current_turn(
&self,
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
if let Some(cancel_called) = &self.cancel_called {
*cancel_called.lock().unwrap() += 1;
}
Ok(())
Ok(domain::ports::CancelCurrentTurnOutcome::Cancelled)
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
if let Some(shutdown_called) = &self.shutdown_called {
@ -4999,8 +5045,10 @@ impl AgentSession for RestartingStructuredSession {
Ok(())
}
async fn cancel_current_turn(&self) -> Result<(), AgentSessionError> {
Ok(())
async fn cancel_current_turn(
&self,
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
Ok(domain::ports::CancelCurrentTurnOutcome::AlreadyCompleted)
}
}

View File

@ -117,11 +117,17 @@ 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> {
async fn cancel_current_turn(
&self,
) -> Result<domain::ports::CancelCurrentTurnOutcome, AgentSessionError> {
self.cancel_called.store(true, Ordering::SeqCst);
Ok(())
Ok(domain::ports::CancelCurrentTurnOutcome::Cancelled)
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
assert!(
self.cancel_called.load(Ordering::SeqCst),
"shutdown must be preceded by cancel_current_turn"
);
self.shutdown_called.store(true, Ordering::SeqCst);
Ok(())
}
@ -530,7 +536,7 @@ async fn stop_structured_shuts_down_and_removes_session() {
assert_eq!(out.stopped[0].session_id, sid(2));
assert_eq!(out.stopped[0].kind, LiveSessionKind::Structured);
assert_eq!(out.stopped[0].state, LiveRuntimeState::Stopped);
assert!(!cancel_called.load(Ordering::SeqCst));
assert!(cancel_called.load(Ordering::SeqCst));
assert!(
shutdown_called.load(Ordering::SeqCst),
"session.shutdown() must be called"
@ -567,8 +573,8 @@ async fn stop_structured_runs_execution_cascade_before_shutdown() {
assert_eq!(out.stopped[0].session_id, sid(2));
assert_eq!(*cascade.calls.lock().unwrap(), vec![(project().id, a)]);
assert!(
!cancel_called.load(Ordering::SeqCst),
"session.cancel_current_turn() is not the runtime stop primitive"
cancel_called.load(Ordering::SeqCst),
"hard stop must cancel the current turn before shutdown"
);
assert!(
shutdown_called.load(Ordering::SeqCst),