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:
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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()));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user