fix(application,backend,app-tauri,frontend): réconcilie l'état idle et les Finals après Stop — #357 (QA verte)

Cause: après un cancel manuel de tour, le ticket médié busy/FIFO n'était pas
retiré (l'agent restait Busy, bouton Stop persistant) et un terminal tardif du
tour annulé pouvait bloquer le tour suivant (Final non affiché).

Backend: StructuredSessions lie chaque génération de tour à son ticket médié
(bind_turn_accounting) et request_turn_cancel restitue l'identité (agent,
ticket) exacte observée avant annulation; CancelAgentChat.with_turn_accounting
retire le ticket exact (cancel_head + mark_idle) seulement s'il est toujours
le ticket courant — jamais un successeur déjà remplacé;
StructuredHumanTurnGuard expose accounting_identity et agent_send (app-tauri)
lie la comptabilité au démarrage du tour humain.

Frontend: CustomAgentChatView réconcilie l'interaction active depuis le
work-state (agent idle sans délégation -> interaction terminée, fallback
« Tour terminé sans réponse finale. ») au lieu de laisser un agentBusyChanged
sans tour-id clore un tour plus récent; les terminaux tardifs (final/error) du
tour read-model-settled remplacent le fallback; tour suivant isolé des
callbacks tardifs de la génération précédente.

Tests: cancel_agent_chat (retrait du ticket exact avant le prompt suivant,
nettoyage tardif sans idle sur ticket successeur), CustomAgentChatView
(isolation tour suivant, réconciliation work-state + remplacement fallback par
Final tardif).

Réserve non bloquante: alignement fixture skills préexistant porté par le
commit test dédié 83659d9a.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 15:13:03 +02:00
parent 83659d9a09
commit 50e395b602
7 changed files with 414 additions and 36 deletions

View File

@ -2,6 +2,8 @@
use std::sync::Arc;
use domain::input::InputMediator;
use domain::mailbox::AgentMailbox;
use domain::ports::{CancelCurrentTurnOutcome, EventBus, PtyPort, SpawnSpec};
use domain::{
AgentId, DomainEvent, NodeId, Project, ProjectPath, PtySize, SessionId, SessionKind,
@ -27,13 +29,28 @@ pub struct CancelAgentChatOutput {
/// Cancels one structured chat turn without stopping its reusable runtime.
pub struct CancelAgentChat {
sessions: Arc<StructuredSessions>,
turn_accounting: Option<(Arc<dyn InputMediator>, Arc<dyn AgentMailbox>)>,
}
impl CancelAgentChat {
/// Builds the use case from the structured runtime registry.
#[must_use]
pub fn new(sessions: Arc<StructuredSessions>) -> Self {
Self { sessions }
Self {
sessions,
turn_accounting: None,
}
}
/// Wires the mediated busy/FIFO state owned by direct human turns.
#[must_use]
pub fn with_turn_accounting(
mut self,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>,
) -> Self {
self.turn_accounting = Some((input, mailbox));
self
}
/// Requests cancellation from the live session and deliberately leaves its
@ -43,7 +60,7 @@ impl CancelAgentChat {
/// 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
let (session, outcome, active_ticket) = self
.sessions
.request_turn_cancel(&session_id)
.ok_or_else(|| AppError::NotFound(format!("structured session {session_id}")))?;
@ -52,6 +69,18 @@ impl CancelAgentChat {
self.sessions.restore_turn_after_cancel_failure(&session_id);
return Err(AppError::from(error));
}
if let (Some((agent, ticket)), Some((input, mailbox))) =
(active_ticket, self.turn_accounting.as_ref())
{
// The provider kill can race the old pump's normal guard cleanup and
// a caller may already have started the next turn. Retire only the
// exact ticket observed before cancellation; never mark a successor
// idle when it has already replaced that ticket.
if input.busy_state(agent).ticket() == Some(ticket) {
mailbox.cancel_head(agent, ticket);
input.mark_idle(agent);
}
}
}
Ok(CancelAgentChatOutput {
session_id,