fix(orchestrator): LiveStateTurnGuard ramène la LED live-state à Done au drop du futur ask cross-project — #281 (RAII armé après publication Working, désarmé sur sorties explicites mark_target_done_best_effort, Drop spawn best-effort UpdateLiveState Done avec last_delegation=ticket; test régression dropped_ask_future_marks_live_state_done; QA verte: cargo fmt, orchestrator_service 78/78, npm build frontend, AppImage rebuildée 2026-09-08 13:52; réserve documentée: échec préexistant launch_conventionfile_injects_assigned_skills_in_order hors périmètre)

This commit is contained in:
2026-09-08 14:54:09 +02:00
parent 3ff62082eb
commit 566e3c3732
2 changed files with 121 additions and 0 deletions

View File

@ -328,6 +328,79 @@ impl Drop for BusyTurnGuard {
}
}
/// Best-effort live-state cleanup paired with [`BusyTurnGuard`].
///
/// `BusyTurnGuard` owns synchronous FIFO/busy cleanup only. Live-state writes are
/// async, so this guard captures the already-materialized updater and spawns a
/// best-effort `Done` transition when the ask future is dropped before an
/// explicit success/error branch can clean the human-facing status row. This is
/// the cross-project cancel/timeout case: the target turn is gone, but the
/// persisted LED must not remain `working` until restart/TTL.
struct LiveStateTurnGuard {
update: Option<Arc<UpdateLiveState>>,
target: AgentId,
ticket: TicketId,
armed: bool,
}
impl LiveStateTurnGuard {
fn new(update: Option<Arc<UpdateLiveState>>, target: AgentId, ticket: TicketId) -> Self {
Self {
update,
target,
ticket,
armed: true,
}
}
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for LiveStateTurnGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let Some(update) = self.update.clone() else {
return;
};
let target = self.target;
let ticket = self.ticket;
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
handle.spawn(async move {
let outcome = update
.execute(UpdateLiveStateInput {
agent_id: target,
ticket: Some(ticket),
intent: String::new(),
status: domain::live_state::WorkStatus::Done,
progress: None,
last_delegation: Some(ticket),
})
.await;
if let Err(e) = outcome {
crate::diag!(
"[live-state] drop cleanup failed: target={target} ticket={ticket} err={e}"
);
} else {
crate::diag!(
"[live-state] drop cleanup marked target done: target={target} ticket={ticket}"
);
}
});
}
Err(_) => {
crate::diag!(
"[live-state] drop cleanup skipped without tokio runtime: target={target} ticket={ticket}"
);
}
}
}
}
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
/// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint).
@ -2122,6 +2195,11 @@ impl OrchestratorService {
Self::distill_intent(&task),
)
.await;
let live_state_update = self
.live_state
.as_ref()
.and_then(|provider| provider.live_state_for(&project.root));
let mut live_state_guard = LiveStateTurnGuard::new(live_state_update, agent_id, ticket_id);
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (cible `Busy`). Couvre
// toutes les sorties — `return Err` des bras du select, OU **futur abandonné
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
@ -2253,6 +2331,7 @@ impl OrchestratorService {
}
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
live_state_guard.disarm();
crate::diag!(
"[rendezvous] ask error (structured): target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={} err={err}",
@ -2271,6 +2350,7 @@ impl OrchestratorService {
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
live_state_guard.disarm();
crate::diag!(
"[rendezvous] ask CEILING (structured, still active): target={target} \
(agent {agent_id}) ticket={ticket_id} after_ms={}",
@ -2290,6 +2370,7 @@ impl OrchestratorService {
.await?;
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
live_state_guard.disarm();
crate::diag!(
"[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)",
@ -2325,6 +2406,7 @@ impl OrchestratorService {
// résultat (chemin structuré) ⇒ `Done` + `last_delegation` = ticket résolu.
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
live_state_guard.disarm();
Ok(self.reply_outcome(agent_id, target, result))
}

View File

@ -2865,6 +2865,45 @@ async fn dropped_ask_future_frees_busy_target() {
);
}
/// Régression cross-project/workstate — si le client MCP appelant abandonne un
/// `idea_project_ask_agent`, le futur `ask` est droppé. Le garde Busy libère déjà
/// la FIFO ; il faut aussi nettoyer le live-state, sinon l'UI garde une LED
/// `working`/jaune jusqu'au restart ou à la TTL.
#[tokio::test]
async fn dropped_ask_future_marks_live_state_done() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
await_until(|| {
fx.live
.entry_for(aid(1))
.is_some_and(|entry| entry.status == WorkStatus::Working)
})
.await;
ask.abort();
let _ = ask.await;
await_until(|| {
fx.live
.entry_for(aid(1))
.is_some_and(|entry| entry.status == WorkStatus::Done)
})
.await;
let entry = fx
.live
.entry_for(aid(1))
.expect("target live entry present");
assert_eq!(
entry.last_delegation, entry.ticket,
"drop cleanup preserves the ticket as last_delegation"
);
}
/// Régression (garde RAII) — une **2e délégation** vers une cible dont un 1er `ask` a
/// été abandonné est bien livrée : l'agent n'est pas coincé `Busy`, son tour suivant
/// démarre et peut être résolu par `idea_reply`.