merge(orchestrator): fix live-state cross-project ask après timeout — #281 (QA verte: cargo test -p application --test orchestrator_service 78/78, fmt, build frontend, AppImage rebuildée; branche fix/281-cross-project-ask-live-state)
This commit is contained in:
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user