fix(backend,orchestrator,chat): tour humain agent_send survit au self-restart — #290 (le tour humain direct agent_send/ws chat.send s'enregistre au RestartRendezvousCoordinator via StructuredHumanTurnGuard: Final/Error de l'ancien provider devient transition de contrôle et non le Final logique; transition_after_provider_terminal_async attend Ready puis accept_ready acquitte le handoff et libère le scheduler, Resume => drain de la session de remplacement avec SELF_RESTART_CONTINUATION_PROMPT, seul le terminal du remplacement est transmis au chat; Failed/Cancelled surfacés explicitement avec libération Busy/FIFO; send_with_tap en erreur pendant restart actif traité comme teardown attendu (stream vide puis attente du remplacement, plus de Final prématuré ni chat bloqué); StopLiveAgent câblé with_restart_rendezvous: stop utilisateur annule le rendez-vous restart; RestartRendezvousRegistration rendu public (state/changed/accept_ready); QA verte: backend self_restart_contract_tests 3/3 dont 2 nouveaux (old terminal ignoré + handoff accepté, failure explicite + busy libéré), application orchestrator_service restart 8/8 dont registration directe du rendez-vous, workstate_actions 9/9, app-tauri chat_bridge 31/31, web-server websocket_chat_send 3/3, cargo check --workspace, fmt --check, diff --check — reconfirmés par Git avant commit; réserves hors périmètre préexistantes: 7 échecs fixture PTY web-server + 2 échecs plugin application; carnet #290 + clôture store #289 inclus; scripts/ hors périmètre; aucun push)

This commit is contained in:
2026-09-09 08:38:29 +02:00
parent 321ebdaab9
commit d03300a20f
16 changed files with 660 additions and 213 deletions

View File

@ -1882,6 +1882,7 @@ async fn ws_send_chat(
backend::StructuredHumanTurnGuard::start(
Arc::clone(&state.app.turn_watch_input),
Arc::clone(&state.app.input_mailbox),
Arc::clone(&state.app.restart_rendezvous),
*project_id,
*agent_id,
&prompt,
@ -1898,6 +1899,9 @@ async fn ws_send_chat(
guard.mark_alive();
}
}
if matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }) {
continue;
}
let chunk = reply_event_chunk(event);
if tap_out
.blocking_send(ServerFrame::chat_output(None, sid, chunk))
@ -1909,35 +1913,128 @@ async fn ws_send_chat(
});
let stream_result = session.send_with_tap(&prompt, tap_tx).await;
let _ = tap_pump.join();
let stream = stream_result.map_err(|err| ErrorDto {
code: "CHAT_SEND_FAILED".to_owned(),
message: err.to_string(),
})?;
let mut turn_guard = turn_guard;
let mut stream = match stream_result {
Ok(stream) => stream,
Err(_err)
if turn_guard
.as_mut()
.is_some_and(|guard| guard.restart_is_active()) =>
{
Box::new(std::iter::empty()) as domain::ports::ReplyStream
}
Err(err) => {
return Err(ErrorDto {
code: "CHAT_SEND_FAILED".to_owned(),
message: err.to_string(),
})
}
};
let mut sent_ack = false;
for event in stream {
if !matches!(event, ReplyEvent::Final { .. }) {
if let Some(guard) = &turn_guard {
guard.mark_alive();
'logical_turn: loop {
let mut concrete_terminal = false;
let mut resume = false;
for event in stream {
let provider_terminal =
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
if provider_terminal {
concrete_terminal = true;
if let Some(guard) = turn_guard.as_mut() {
match guard.transition_after_provider_terminal_async().await {
backend::StructuredHumanTurnTransition::Complete => {}
backend::StructuredHumanTurnTransition::Resume => {
resume = true;
break;
}
backend::StructuredHumanTurnTransition::Failed(message) => {
let chunk = reply_event_chunk(ReplyEvent::Error { message });
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
chunk,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
break 'logical_turn;
}
backend::StructuredHumanTurnTransition::Cancelled => {
break 'logical_turn;
}
}
}
} else {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event);
if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
} else {
tx.send(ServerFrame::chat_output(None, sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
}
}
let chunk = reply_event_chunk(event);
if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
} else {
tx.send(ServerFrame::chat_output(None, sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
if !resume && !concrete_terminal {
if let Some(guard) = turn_guard.as_mut() {
match guard.transition_after_provider_terminal_async().await {
backend::StructuredHumanTurnTransition::Complete => {}
backend::StructuredHumanTurnTransition::Resume => resume = true,
backend::StructuredHumanTurnTransition::Failed(message) => {
let chunk = reply_event_chunk(ReplyEvent::Error { message });
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
chunk,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
}
backend::StructuredHumanTurnTransition::Cancelled => break,
}
}
}
if !resume {
break;
}
let Some((project_id, agent_id, _, _)) = session_meta.as_ref() else {
break;
};
let replacement = state
.app
.structured_sessions
.session_for_agent_in_project(*project_id, agent_id)
.ok_or_else(|| ErrorDto {
code: "CHAT_RESTART_FAILED".to_owned(),
message: "restart completed without a replacement structured session".to_owned(),
})?;
stream = replacement
.send(application::SELF_RESTART_CONTINUATION_PROMPT)
.await
.map_err(|err| ErrorDto {
code: "CHAT_RESTART_FAILED".to_owned(),
message: err.to_string(),
})?;
}
if !sent_ack {