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:
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user