fix(application,backend,web-server,app-tauri,frontend): libère l'état busy et le ticket médiatisé avant le Final visible — web-server ws_send_chat: guard.finish() sur Complete et Failed avant l'émission des frames terminales client, et sur l'absence d'ack, pour qu'un read workstate déclenché par Final/Error ne puisse plus voir le tour comme Busy ; app-tauri agent_send: dès la transition Complete, retire le ticket médiatisé exact (guard.finish) et termine le tour structuré (finish_turn) avant send_output_settled, opérations idempotentes et clôturées contre un successeur, épilogue conservé en backstop drop/error — corrige LED projet orange persistante et queue non drainée après Final ; frontend CustomAgentChatView: terminalWorkStatePending maintient effectiveBusy jusqu'à la résolution du refresh workstate post-Final au lieu de forcer busy=false et stopTerminalOverride (plus d'état busy fantôme ni de Stop incorrectement neutralisé) — #369 (QA verte: cargo fmt --all --check OK, cargo test application structured OK, web-server websocket_chat_send_releases_busy_before_visible_final et websocket_chat_send --test-threads=1 OK, app-tauri chat_bridge 36 tests OK, application drain_with_readiness_lot1/session_limit_t4/send_blocking_d1/cancel_agent_chat OK, backend human_turn OK, npm test CustomAgentChatView 92 OK, npm run build OK)

This commit is contained in:
2026-09-17 09:46:21 +02:00
parent 4c86f9bb6f
commit 2d95e20d91
5 changed files with 217 additions and 15 deletions

View File

@ -521,6 +521,48 @@ mod tests {
}
}
struct BusyRecordingMediator {
state: Mutex<AgentBusyState>,
calls: Mutex<Vec<&'static str>>,
}
impl BusyRecordingMediator {
fn new(ticket: TicketId) -> Self {
Self {
state: Mutex::new(AgentBusyState::Busy {
ticket,
since_ms: 1,
}),
calls: Mutex::new(Vec::new()),
}
}
}
impl InputMediator for BusyRecordingMediator {
fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
unreachable!("non utilisé par drain_with_readiness")
}
fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, _agent: RuntimeAgentKey) {
*self.state.lock().unwrap() = AgentBusyState::Idle;
self.calls.lock().unwrap().push("idle");
}
fn mark_alive(&self, _agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push("alive");
}
fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState {
*self.state.lock().unwrap()
}
fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {}
fn bind_handle_with_submit(
&self,
_agent: RuntimeAgentKey,
_handle: PtyHandle,
_submit: SubmitConfig,
) {
}
}
#[derive(Default)]
struct RecordingBus {
events: Mutex<Vec<DomainEvent>>,
@ -562,6 +604,32 @@ mod tests {
);
}
#[tokio::test]
async fn structured_final_leaves_no_busy_state_behind() {
let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(369));
let session = FakeSession {
events: vec![
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: "terminé".into(),
},
],
};
let mediator = BusyRecordingMediator::new(ticket);
let out = drain_with_readiness(&session, "go", None, &mediator, key(369))
.await
.expect("Final termine le tour structuré");
assert_eq!(out, "terminé");
assert_eq!(mediator.busy_state(key(369)), AgentBusyState::Idle);
assert_eq!(
*mediator.calls.lock().unwrap(),
vec!["alive", "idle"],
"le Final doit publier exactement une transition terminale après l'activité"
);
}
#[tokio::test]
async fn drain_publishes_announcement_but_only_final_marks_idle_and_resolves() {
let session = FakeSession {