fix(application,backend,app-tauri): libère l'état busy après Final et draine la queue des messages agents — Final/Error terminent immédiatement le pump structuré via reply_event_ends_turn (le provider n'est pas tenu de fermer son itérateur), release busy/FIFO idempotente bornée au ticket observé (release_turn_accounting: cancel_head + mark_idle seulement si le ticket appartient toujours au tour), cleanup tardif Final/close/timeout/cancel sans libérer un tour successeur, factoring CancelAgentChat sur la même primitive, régression #357 préservée — #362 (QA verte: cargo fmt --check, chat_bridge 36 tests, cancel_agent_chat 4 tests, backend 106 tests, drain_with_readiness_lot1 7 tests)

This commit is contained in:
2026-09-16 20:46:38 +02:00
parent 208e14961b
commit 12f7272fbe
7 changed files with 160 additions and 29 deletions

View File

@ -26,6 +26,28 @@ pub struct CancelAgentChatOutput {
pub outcome: CancelCurrentTurnOutcome,
}
/// Releases exactly one mediated turn without allowing late terminal cleanup to
/// affect a successor that already owns the agent.
///
/// `cancel_head` and `mark_idle` are individually idempotent, while the ticket
/// checks fence Final/close/timeout/cancel races across successive turns.
pub fn release_turn_accounting(
input: &dyn InputMediator,
mailbox: &dyn AgentMailbox,
agent: domain::RuntimeAgentKey,
ticket: domain::mailbox::TicketId,
) -> bool {
if input.busy_state(agent).ticket() != Some(ticket) {
return false;
}
mailbox.cancel_head(agent, ticket);
if input.busy_state(agent).ticket() != Some(ticket) {
return false;
}
input.mark_idle(agent);
true
}
/// Cancels one structured chat turn without stopping its reusable runtime.
pub struct CancelAgentChat {
sessions: Arc<StructuredSessions>,
@ -72,14 +94,7 @@ impl CancelAgentChat {
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);
}
release_turn_accounting(input.as_ref(), mailbox.as_ref(), agent, ticket);
}
}
Ok(CancelAgentChatOutput {