fix(custom-cli): busy/progress/tools/chatbox bloqués après le premier appel — #245 (QA verte)

Backend : StructuredHumanTurnGuard pour partager la comptabilité busy/FIFO entre chat direct humain (agent_send, web chat.send) et asks inter-agents structurés, avec liveness mark_alive et retrait idempotent du tour. Web-server : tap d'événements sur chat.send pour le scrollback temps réel. Frontend : scoping des clés de progression par tour pour éviter la déduplication/ghosting des progress et outils entre appels.
This commit is contained in:
2026-08-23 13:34:56 +02:00
parent 55637abd6d
commit f1d85dfc5a
5 changed files with 728 additions and 58 deletions

View File

@ -836,6 +836,87 @@ struct AppAgentResumer {
contexts: ResumeContexts,
}
/// Guard for a direct human turn sent to a structured agent session.
///
/// Direct chat adapters (`agent_send`, web `chat.send`) still drive the provider
/// through `AgentSession::send`, but they must share the same busy/FIFO accounting
/// as structured inter-agent asks. This guard starts a silent human turn and
/// retires it on terminal outcome or drop.
pub struct StructuredHumanTurnGuard {
input: Arc<dyn domain::input::InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: domain::RuntimeAgentKey,
ticket_id: domain::mailbox::TicketId,
armed: Arc<std::sync::atomic::AtomicBool>,
finish_on_drop: bool,
}
impl StructuredHumanTurnGuard {
/// Starts the mediated busy turn for a direct human structured chat message.
#[must_use]
pub fn start(
input: Arc<dyn domain::input::InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
project_id: ProjectId,
agent_id: AgentId,
prompt: &str,
) -> Self {
let agent = domain::RuntimeAgentKey::new(project_id, agent_id);
let ticket_id = domain::mailbox::TicketId::new_random();
let owns_turn = if input.busy_state(agent).is_busy() {
false
} else {
let ticket = domain::mailbox::Ticket::new(ticket_id, "user", prompt);
let _pending = input.enqueue_silent(agent, ticket);
input.busy_state(agent).ticket() == Some(ticket_id)
};
Self {
input,
mailbox,
agent,
ticket_id,
armed: Arc::new(std::sync::atomic::AtomicBool::new(owns_turn)),
finish_on_drop: true,
}
}
/// Returns a non-owning handle that can keep the turn alive from relay code.
#[must_use]
pub fn liveness_handle(&self) -> Self {
Self {
input: Arc::clone(&self.input),
mailbox: Arc::clone(&self.mailbox),
agent: self.agent,
ticket_id: self.ticket_id,
armed: Arc::clone(&self.armed),
finish_on_drop: false,
}
}
/// Records proof of liveness for the running turn.
pub fn mark_alive(&self) {
if self.armed.load(std::sync::atomic::Ordering::Acquire) {
self.input.mark_alive(self.agent);
}
}
/// Retires the FIFO head and marks the agent idle. Idempotent across clones.
pub fn finish(&self) {
if self.armed.swap(false, std::sync::atomic::Ordering::AcqRel) {
self.mailbox.cancel_head(self.agent, self.ticket_id);
self.input.mark_idle(self.agent);
}
}
}
impl Drop for StructuredHumanTurnGuard {
fn drop(&mut self) {
if self.finish_on_drop {
self.finish();
}
}
}
#[async_trait::async_trait]
impl AgentResumer for AppAgentResumer {
async fn resume(
@ -1384,6 +1465,9 @@ pub struct BackendCore {
/// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Mailbox paired with [`Self::turn_watch_input`], used by direct structured
/// human turns to retire the synthetic busy/FIFO ticket on terminal outcome.
pub input_mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
/// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
/// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
/// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
@ -3259,6 +3343,7 @@ impl BackendCore {
resume_contexts,
turn_watcher,
turn_watch_input: Arc::clone(&input_mediator),
input_mailbox: Arc::clone(&mailbox),
turn_watch_handles: Mutex::new(HashMap::new()),
fs_port: Arc::clone(&fs_port),
home_dir,