feat(agent): robustesse routage ask — fix registre session + concurrence (R0+A0) — §14.3/§15

Stabilise le routage de `ask` avant le bind transport MCP (v5). Invariant
« 1 agent = 1 employé » durci ; un agent traite un tour à la fois.

- R0a garde LaunchAgent : lève AgentAlreadyRunning pour un lancement neuf
  ciblant un agent déjà vivant sur un autre node (PTY + structuré) ; rebind
  seulement même-node ou réattache explicite (conversation_id). Idem spawn_agent.
- R0b list_live_agents agrège PTY + structuré (LiveSessions) + dédup.
- R0c réconciliation des layouts.json à doublons à l'ouverture (host déterministe,
  idempotent) — corrige « une cellule reset au retour d'onglet ».
- R0d UI : option agent désactivée si vivant ailleurs + « aller à la cellule »,
  mapping AGENT_ALREADY_RUNNING.
- A0 sérialisation FIFO des tours par agent_id dans ask_agent (verrou tokio par
  agent ; agents différents en parallèle ; timeout tour 300s, cap attente 600s).

Cadrage : .ideai/briefs/orchestration-v5-transport-bind-cadrage.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 17:34:57 +02:00
parent 37e72747d3
commit 6ca519b815
21 changed files with 2402 additions and 85 deletions

View File

@ -21,6 +21,7 @@
//! `TerminalSessions` registry, keyed by that same `SessionId`.
mod management;
mod reconcile;
mod snapshot;
mod store;
mod usecases;
@ -30,6 +31,7 @@ pub use management::{
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
};
pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOutput};
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};

View File

@ -0,0 +1,101 @@
//! [`ReconcileLayouts`] — réconcilie, à l'**ouverture** d'un projet, les
//! `layouts.json` qui contiennent des feuilles en **doublon** sur un même agent
//! (lot R0c du cadrage orchestration v5, §3.4 « Trou C »).
//!
//! C'est la **jumelle** de [`super::snapshot::SnapshotRunningAgents`] : là où le
//! snapshot **gèle** `agent_was_running` à la *fermeture*, cette réconciliation
//! **dé-doublonne** à l'*ouverture*. Un `layouts.json` persisté peut déjà porter
//! deux feuilles sur le **même** `agent` id ; à la réouverture il ne faut **pas**
//! relancer la 2ᵉ. On garde **une** feuille « hôte » (potentiellement vivante /
//! reprenable) et on transforme les autres en **vues mortes** : leur
//! `agent_was_running` repasse à `false` et leur `conversation_id` est retiré.
//! C'est précisément ce qui éliminait le symptôme « une cellule reset au retour
//! d'onglet ».
//!
//! Le use case est un mince orchestrateur au-dessus de :
//! - le store des layouts persistés ([`super::store`]),
//! - l'opération pure du domaine
//! [`domain::LayoutTree::reconcile_duplicate_agents`] (qui porte la **règle
//! déterministe de choix de l'hôte** et la garantie d'idempotence).
//!
//! **Idempotence / no-op** : un layout sans doublon (ou déjà réconcilié) ressort
//! **identique** de l'opération pure ; on ne persiste alors **rien** (aucune
//! écriture). Une 2ᵉ ouverture du même projet réconcilié est donc un no-op.
//!
//! **Ordre d'insertion** : à appeler à l'ouverture du projet, **après** le
//! chargement/résolution des layouts et **avant** toute reprise
//! ([`crate::ListResumableAgents`]) — qui relit la version persistée — de sorte
//! qu'on ne propose / ne relance qu'**une** session par agent.
use std::sync::Arc;
use domain::ports::{FileSystem, ProjectStore};
use domain::ProjectId;
use crate::error::AppError;
use super::store::{persist_doc, resolve_doc};
/// Input de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReconcileLayoutsInput {
/// Le projet dont les layouts doivent être dé-doublonnés.
pub project_id: ProjectId,
}
/// Output de [`ReconcileLayouts::execute`].
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcileLayoutsOutput {
/// `true` si au moins un layout a été modifié (et donc le doc persisté).
/// `false` ⇒ aucun doublon : no-op, aucune écriture (idempotence).
pub changed: bool,
}
/// Réconcilie les feuilles d'agent en doublon de tous les layouts d'un projet,
/// puis persiste le doc **uniquement** s'il a changé.
pub struct ReconcileLayouts {
store: Arc<dyn ProjectStore>,
fs: Arc<dyn FileSystem>,
}
impl ReconcileLayouts {
/// Construit le use case à partir de ses ports injectés.
#[must_use]
pub fn new(store: Arc<dyn ProjectStore>, fs: Arc<dyn FileSystem>) -> Self {
Self { store, fs }
}
/// Exécute la réconciliation pour un projet.
///
/// Pour chaque layout, applique l'opération pure
/// [`domain::LayoutTree::reconcile_duplicate_agents`]. Si **aucun** arbre n'a
/// changé, ne persiste **rien** (no-op idempotent). Sinon, persiste tout le
/// doc une fois.
///
/// # Errors
/// - [`AppError::NotFound`] si le projet est inconnu,
/// - [`AppError::Store`] sur défaillance du registre,
/// - [`AppError::FileSystem`] sur défaillance de persistance.
pub async fn execute(
&self,
input: ReconcileLayoutsInput,
) -> Result<ReconcileLayoutsOutput, AppError> {
let project = self.store.load_project(input.project_id).await?;
let mut doc = resolve_doc(self.fs.as_ref(), &project).await?;
let mut changed = false;
for named in &mut doc.layouts {
let reconciled = named.tree.reconcile_duplicate_agents();
if reconciled != named.tree {
named.tree = reconciled;
changed = true;
}
}
if changed {
persist_doc(self.fs.as_ref(), &project, &doc).await?;
}
Ok(ReconcileLayoutsOutput { changed })
}
}