feat(app): auto-update live-state depuis le cycle de délégation (LS3)

Branche la mise à jour automatique du live-state sur le cycle de
délégation de l'orchestrateur, en best-effort non bloquant.
- Trait `LiveStateProvider` (résolution par root) + champ `Option` dans
  l'`OrchestratorService`, injecté via le builder `with_live_state` ;
  `None` ⇒ aucun effet (zéro régression).
- Transitions dérivées du cycle : `ask` → entrée `Working`,
  `reply` → `Done` + `last_delegation`.
- Helpers best-effort : un échec de mise à jour du live-state ne
  transforme jamais un succès de délégation en erreur.
- `AppLiveStateProvider` + wiring côté app-tauri (provider par root).

cargo test -p application : 0 échec ; --test orchestrator_service : 55/0
(dont 4 nouveaux tests LS3) ; cargo fmt --all --check : exit 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 19:28:02 +02:00
parent 18401116aa
commit 9d46e6cd21
5 changed files with 467 additions and 42 deletions

View File

@ -81,8 +81,8 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,

View File

@ -12,6 +12,6 @@ pub use context_guard::{
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{
ContextGuardUseCases, McpRuntimeProvider, OrchestratorOutcome, OrchestratorService,
RecordTurnProvider,
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
OrchestratorService, RecordTurnProvider,
};

View File

@ -45,6 +45,7 @@ use crate::orchestrator::{
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::workstate::{UpdateLiveState, UpdateLiveStateInput};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
/// resizes the PTY to the real cell size on attach; these are sane starting rows
@ -214,6 +215,26 @@ pub trait RecordTurnProvider: Send + Sync {
fn record_turn_for(&self, root: &ProjectPath) -> Option<Arc<RecordTurn>>;
}
/// Provider **par project root** d'un [`UpdateLiveState`] (lot LS3), calqué sur
/// [`RecordTurnProvider`].
///
/// Même tension résolue de la même façon : l'[`OrchestratorService`] est **unique**
/// pour tous les projets, mais le live-state est **par project root**
/// (`<root>/.ideai/live-state.json`) — et le port [`domain::ports::LiveStateStore`]
/// fixe son root à la construction (pas de paramètre `root` par appel, contrairement
/// au `MemoryStore` du harvest). `ask_agent` connaît le `project.root` du tour et
/// demande au provider de matérialiser un `UpdateLiveState` ciblant le **bon** dossier
/// (les adapters `Fs*` ne font que des jointures de chemin, leur construction est
/// triviale).
///
/// `None` ⇒ aucun auto-update : zéro régression. Implémenté dans app-tauri (seul
/// détenteur des adapters `Fs*` et de l'horloge).
pub trait LiveStateProvider: Send + Sync {
/// Construit l'[`UpdateLiveState`] dont le store cible `root`. Appelé une fois par
/// transition best-effort ; `None` ⇒ on saute silencieusement l'auto-update.
fn live_state_for(&self, root: &ProjectPath) -> Option<Arc<UpdateLiveState>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -308,6 +329,14 @@ pub struct OrchestratorService {
/// port). Injecté via [`Self::with_read_skill`] ; `None` ⇒ la commande
/// `skill.read` renvoie une erreur typée (call sites/tests legacy restent verts).
read_skill: Option<Arc<ReadSkill>>,
/// Auto-update du **live-state** (programme live-state, lot LS3), dérivé des
/// transitions de délégation déjà existantes (zéro token agent, zéro appel
/// modèle) : la cible passe `Working` quand un `ask` est accepté, puis `Done`
/// (+ `last_delegation`) quand elle rend son résultat. Injecté via
/// [`Self::with_live_state`] ; `None` (défaut) ⇒ aucun auto-update (zéro
/// régression). **Best-effort strict** : un échec de persistance ne transforme
/// **jamais** un succès de délégation/reply en erreur.
live_state: Option<Arc<dyn LiveStateProvider>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -373,6 +402,7 @@ impl OrchestratorService {
structured: None,
memory_harvest: None,
read_skill: None,
live_state: None,
}
}
@ -489,6 +519,94 @@ impl OrchestratorService {
self
}
/// Branche l'auto-update du live-state (lot LS3). Builder additif (signature de
/// [`Self::new`] inchangée) ; `None` (défaut) ⇒ aucun auto-update. Prend un
/// **provider par root** (calqué sur `with_record_turn`) car le live-state est par
/// project root et le port fixe son root à la construction.
#[must_use]
pub fn with_live_state(mut self, live_state: Arc<dyn LiveStateProvider>) -> Self {
self.live_state = Some(live_state);
self
}
/// Distille l'objectif d'un ticket en un `intent` court pour le live-state :
/// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`]
/// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne
/// dépasse jamais le seuil dur anti-dump du domaine (un `ask` peut porter une tâche
/// volumineuse, que `LiveEntry::new` rejetterait sinon — l'auto-update serait alors
/// silencieusement perdu).
fn distill_intent(task: &str) -> String {
task.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.chars()
.take(domain::live_state::FIELD_PREVIEW_MAX_CHARS)
.collect()
}
/// Pose **best-effort** la cible d'une délégation acceptée en `Working` sur le
/// live-state (lot LS3). No-op silencieux quand l'auto-update n'est pas câblé.
/// Tout échec est avalé (un diag, jamais une erreur propagée) : l'auto-update
/// dérivé ne doit **jamais** faire échouer une délégation. N'écrit **que** sur
/// cette transition d'`ask` (jamais par tour/outil) ⇒ pas de write-storm.
async fn mark_target_working_best_effort(
&self,
root: &ProjectPath,
target: AgentId,
ticket: TicketId,
intent: String,
) {
let Some(provider) = &self.live_state else {
return;
};
let Some(update) = provider.live_state_for(root) else {
return;
};
let outcome = update
.execute(UpdateLiveStateInput {
agent_id: target,
ticket: Some(ticket),
intent,
status: domain::live_state::WorkStatus::Working,
progress: None,
last_delegation: None,
})
.await;
if let Err(e) = outcome {
crate::diag!("[live-state] working auto-update failed: target={target} err={e}");
}
}
/// Pose **best-effort** la cible en `Done` (+ `last_delegation` = ticket résolu)
/// sur le live-state (lot LS3), quand elle vient de rendre son résultat. Mêmes
/// garanties best-effort/no-storm que [`Self::mark_target_working_best_effort`].
async fn mark_target_done_best_effort(
&self,
root: &ProjectPath,
target: AgentId,
ticket: TicketId,
) {
let Some(provider) = &self.live_state else {
return;
};
let Some(update) = provider.live_state_for(root) else {
return;
};
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] done auto-update failed: target={target} err={e}");
}
}
/// Persiste **best-effort** un tour (`Prompt`/`Response`) dans `conversation`.
///
/// No-op silencieux quand le provider/horloge ne sont pas câblés, quand le provider
@ -1069,6 +1187,9 @@ impl OrchestratorService {
task.clone(),
)
.await;
// Live-state (lot LS3) : distiller l'intent AVANT que `task` ne soit déplacé
// dans le `Ticket`. Posé sur la cible après l'enqueue (délégation acceptée).
let working_intent = Self::distill_intent(&task);
let ticket = match requester {
Some(from) => {
Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task)
@ -1080,6 +1201,10 @@ impl OrchestratorService {
// le médiateur AVANT l'enqueue (consommé au start_turn).
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let pending = input.enqueue(agent_id, ticket);
// Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur
// cette transition d'`ask` acceptée. N'altère jamais le succès de la délégation.
self.mark_target_working_best_effort(&project.root, agent_id, ticket_id, working_intent)
.await;
// Rendezvous beacon (diagnostics) : l'ask est désormais en attente du
// `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en
// texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de
@ -1123,6 +1248,10 @@ impl OrchestratorService {
// Best-effort strict — n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result)
.await;
// Auto-update live-state (lot LS3), best-effort : la cible vient de rendre
// son résultat ⇒ `Done` + `last_delegation` = ticket résolu.
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
// Succès : désarmer le garde AVANT de retourner. Le `mark_idle` propre
// sur cette branche est porté par le médiateur (prompt-ready / idea_reply
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
@ -1240,6 +1369,16 @@ impl OrchestratorService {
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let pending = input.enqueue(agent_id, ticket);
// Auto-update live-state (lot LS3), best-effort : la cible passe `Working` sur
// cette transition d'`ask` acceptée (chemin structuré). `task` est encore vivant
// ici (utilisé par le drain plus bas), on le distille directement.
self.mark_target_working_best_effort(
&project.root,
agent_id,
ticket_id,
Self::distill_intent(&task),
)
.await;
// 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
@ -1346,6 +1485,10 @@ impl OrchestratorService {
// Best-effort strict — un échec n'altère jamais ce succès de délégation.
self.harvest_memory_best_effort(&project.root, &result)
.await;
// Auto-update live-state (lot LS3), best-effort : la cible vient de rendre son
// résultat (chemin structuré) ⇒ `Done` + `last_delegation` = ticket résolu.
self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
.await;
Ok(self.reply_outcome(agent_id, target, result))
}