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:
@ -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,
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user