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

@ -2564,6 +2564,16 @@ pub async fn agent_send(
.iter()
.map(|attachment| attachment.filename.clone())
.collect();
let session_meta = state.structured_sessions.meta_for_session(&sid);
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
backend::StructuredHumanTurnGuard::start(
std::sync::Arc::clone(&state.turn_watch_input),
std::sync::Arc::clone(&state.input_mailbox),
*project_id,
*agent_id,
&prompt_for_model,
)
});
// Retain the human submit in the same live scrollback as model chunks so
// `reattach_agent_chat` can repaint the whole in-flight conversation.
@ -2583,9 +2593,15 @@ pub async fn agent_send(
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_bridge = std::sync::Arc::clone(&state.chat_bridge);
let tap_sid = sid;
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || {
let mut settler = crate::chat::ProgressSettler::default();
for event in tap_rx {
if !matches!(event, domain::ports::ReplyEvent::Final { .. }) {
if let Some(guard) = &tap_turn_guard {
guard.mark_alive();
}
}
let Some(chunk) = crate::chat::chunk_from_event(event) else {
continue;
};
@ -2617,11 +2633,16 @@ pub async fn agent_send(
// event then feeds the service (détecter→planifier). Without a conversation id the
// service must not pretend an automatic resume is armed.
let service = std::sync::Arc::clone(&state.session_limit_service);
let meta = state.structured_sessions.meta_for_session(&sid);
let meta = session_meta;
std::thread::spawn(move || {
let mut terminal_visible = false;
let mut settler = crate::chat::ProgressSettler::default();
for event in stream {
if !matches!(event, domain::ports::ReplyEvent::Final { .. }) {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
// Non-terminal, sans contenu chat : un signal de limite alimente le service
// (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement.
@ -2663,6 +2684,9 @@ pub async fn agent_send(
let chunk = crate::chat::unfinished_turn_chunk();
let _ = bridge.send_output_settled(&sid, &mut settler, chunk);
}
if let Some(guard) = &turn_guard {
guard.finish();
}
bridge.detach_if(&sid, gen);
});