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:
@ -874,18 +874,40 @@ impl LaunchAgent {
|
||||
// (§17.4) : un agent est vivant s'il a une session PTY *ou* structurée.
|
||||
// On consulte d'abord le registre PTY (chemin historique), puis le
|
||||
// registre structuré (le cas échéant).
|
||||
//
|
||||
// R0a — discrimination réattache-de-vue vs second lancement (cadrage v5
|
||||
// §3.2, Trou A). Un agent est un **singleton** : une seule session vivante.
|
||||
// Quand l'agent est déjà vivant, on distingue (cf. [`Self::reattach_decision`]) :
|
||||
// - **réattache de vue** (rebind sans respawn) : le `node_id` demandé est
|
||||
// le node hôte vivant, OU la cellule porte une `conversation_id`
|
||||
// (réattache explicite — la cellule sait que l'agent tournait) ;
|
||||
// - **lancement background/idempotent** : ni node, ni conversation ⇒ no-op
|
||||
// (rend la session existante, pas de respawn) ;
|
||||
// - **second lancement neuf** : on vise un **autre** node, sans signal de
|
||||
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté).
|
||||
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||||
if let Some(node_id) = input.node_id {
|
||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
structured: None,
|
||||
let host_node = self.sessions.node_for_agent(&input.agent_id);
|
||||
match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id)
|
||||
{
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
structured: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
ReattachDecision::Idempotent => {}
|
||||
ReattachDecision::Refuse { node_id } => {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Idempotent — hand back the already-registered session, no respawn,
|
||||
// nothing new to persist.
|
||||
// Idempotent (or a rebind that found no entry to move) — hand back the
|
||||
// already-registered session, no respawn, nothing new to persist.
|
||||
if let Some(session) = self.sessions.session(&existing_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
@ -894,18 +916,30 @@ impl LaunchAgent {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Garde structurée (§17.4) : même sémantique côté registre IA. Rebind de la
|
||||
// cellule-vue si un node est demandé, sinon idempotence (pas de redémarrage).
|
||||
// Garde structurée (§17.4) : même sémantique côté registre IA. R0a appliqué de
|
||||
// façon identique — rebind de la cellule-vue pour une réattache légitime,
|
||||
// idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
|
||||
if let Some(structured) = &self.structured {
|
||||
if let Some(existing) = structured.session_for_agent(&input.agent_id) {
|
||||
// Cellule-vue : le node demandé, sinon le node hôte courant, sinon un
|
||||
// node neuf (rebind sans redémarrer le process, « la cellule est une
|
||||
// vue »).
|
||||
let node_id = input.node_id.or_else(|| structured.node_for_agent(&input.agent_id));
|
||||
if let Some(node_id) = node_id {
|
||||
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
|
||||
}
|
||||
let node_id = node_id.unwrap_or_else(NodeId::new_random);
|
||||
let host_node = structured.node_for_agent(&input.agent_id);
|
||||
let node_id = match reattach_decision(
|
||||
input.node_id,
|
||||
host_node,
|
||||
input.conversation_id.as_deref(),
|
||||
) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
let _ = structured.rebind_agent_node(&input.agent_id, node_id);
|
||||
node_id
|
||||
}
|
||||
// Idempotent — garder le node hôte courant, sinon un node neuf.
|
||||
ReattachDecision::Idempotent => host_node.unwrap_or_else(NodeId::new_random),
|
||||
ReattachDecision::Refuse { node_id } => {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
return Ok(LaunchAgentOutput {
|
||||
session: structured_snapshot(&existing, input.agent_id, node_id, size),
|
||||
assigned_conversation_id: None,
|
||||
@ -1339,6 +1373,80 @@ impl LaunchAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of the R0a discrimination between a legitimate **view reattach** and a
|
||||
/// **fresh second launch** of an already-live agent (cadrage v5 §3.2, Trou A).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ReattachDecision {
|
||||
/// Rebind the live session's view to `node_id` without respawning (the request
|
||||
/// targets the live host node, or carries an explicit reattach signal).
|
||||
Rebind { node_id: NodeId },
|
||||
/// Background / idempotent no-op: hand back the existing session unchanged
|
||||
/// (neither a node nor a conversation id was supplied).
|
||||
Idempotent,
|
||||
/// Refuse the launch — a genuine second launch of a singleton agent already
|
||||
/// live on `node_id` (the host node, reported in `AgentAlreadyRunning`).
|
||||
Refuse { node_id: NodeId },
|
||||
}
|
||||
|
||||
impl ReattachDecision {
|
||||
/// Decides reattach vs refuse for an already-live agent. See [`reattach_decision`].
|
||||
#[must_use]
|
||||
pub(crate) fn resolve(
|
||||
requested_node: Option<NodeId>,
|
||||
host_node: Option<NodeId>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> Self {
|
||||
reattach_decision(requested_node, host_node, conversation_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether a launch hitting an already-live agent is a legitimate view
|
||||
/// **reattach** (rebind), a **background/idempotent** no-op, or a **refused** second
|
||||
/// launch (cadrage v5 §3.2, Trou A). Pure (no I/O), unit-testable.
|
||||
///
|
||||
/// Signals (both already present in the launch flow):
|
||||
/// - `requested_node` — the hosting leaf the caller targets (`None` ⇒ no cell, e.g.
|
||||
/// a background launch);
|
||||
/// - `host_node` — the node currently hosting the agent's live session, if known;
|
||||
/// - `conversation_id` — the conversation id recorded on the hosting cell. Its
|
||||
/// presence means the **cell knows the agent was running** ⇒ it is a reattach of a
|
||||
/// view onto an in-progress session, not a brand-new launch.
|
||||
///
|
||||
/// Rules:
|
||||
/// - requested node **is** the host node ⇒ `Rebind` (re-open of the same cell);
|
||||
/// - a `conversation_id` is present ⇒ `Rebind` (explicit reattach, even on another
|
||||
/// node — the cell is a rebindable view, §17.6);
|
||||
/// - no node **and** no conversation ⇒ `Idempotent` (background/no-op relaunch);
|
||||
/// - a different node, no reattach signal ⇒ `Refuse` (genuine second launch).
|
||||
fn reattach_decision(
|
||||
requested_node: Option<NodeId>,
|
||||
host_node: Option<NodeId>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> ReattachDecision {
|
||||
// Explicit reattach: the cell carries a conversation id, so it is re-binding a
|
||||
// view onto an already-running session. Rebind to the requested node, or keep the
|
||||
// current host node when none was supplied.
|
||||
if conversation_id.is_some() {
|
||||
if let Some(node_id) = requested_node.or(host_node) {
|
||||
return ReattachDecision::Rebind { node_id };
|
||||
}
|
||||
return ReattachDecision::Idempotent;
|
||||
}
|
||||
|
||||
match requested_node {
|
||||
// Same cell re-opened ⇒ idempotent rebind (no respawn).
|
||||
Some(node) if host_node == Some(node) => ReattachDecision::Rebind { node_id: node },
|
||||
// A different cell with no reattach signal ⇒ genuine second launch: refuse,
|
||||
// reporting the live host node (falling back to the requested node only if the
|
||||
// host is somehow unknown, so the error always carries a meaningful cell).
|
||||
Some(node) => ReattachDecision::Refuse {
|
||||
node_id: host_node.unwrap_or(node),
|
||||
},
|
||||
// No node and no reattach signal ⇒ background/idempotent no-op.
|
||||
None => ReattachDecision::Idempotent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the IdeA MCP server declaration written into a CLI's `ConfigFile` MCP
|
||||
/// config (cadrage v3, Décision 3). Coherent **placeholder** for M1: it describes a
|
||||
/// stdio/socket server launched by the `idea` binary — the exact command and the
|
||||
|
||||
@ -14,6 +14,7 @@ mod structured;
|
||||
mod usecases;
|
||||
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
pub(crate) use lifecycle::ReattachDecision;
|
||||
|
||||
pub use structured::send_blocking;
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
101
crates/application/src/layout/reconcile.rs
Normal file
101
crates/application/src/layout/reconcile.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -59,9 +59,10 @@ pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
ListLayoutsInput, ListLayoutsOutput, LoadLayout, LoadLayoutInput, LoadLayoutOutput,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents,
|
||||
SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
MutateLayout, MutateLayoutInput, MutateLayoutOutput, NamedLayout, ReconcileLayouts,
|
||||
ReconcileLayoutsInput, ReconcileLayoutsOutput, RenameLayout, RenameLayoutInput,
|
||||
SetActiveLayout, SetActiveLayoutInput, SnapshotRunningAgents, SnapshotRunningAgentsInput,
|
||||
SnapshotRunningAgentsOutput, LAYOUTS_FILE,
|
||||
};
|
||||
pub use memory::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
|
||||
@ -13,15 +13,18 @@
|
||||
//! no filesystem watching, no JSON, no process spawning here — those are the
|
||||
//! infrastructure adapter's job. That keeps this fully unit-testable with fakes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use domain::ports::{EventBus, ProfileStore};
|
||||
use domain::{DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
|
||||
use crate::agent::{
|
||||
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
|
||||
ListAgents, ListAgentsInput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
use crate::error::AppError;
|
||||
use crate::skill::{CreateSkill, CreateSkillInput};
|
||||
@ -43,6 +46,21 @@ const DEFAULT_COLS: u16 = 80;
|
||||
/// without changing the contract).
|
||||
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Borne d'attente **en file** pour acquérir le verrou de tour d'un agent (A0,
|
||||
/// cadrage v5 §4).
|
||||
///
|
||||
/// La sérialisation FIFO par agent (« 1 agent = 1 employé : un tour à la fois »)
|
||||
/// fait qu'un `ask` concurrent vers la **même** cible patiente derrière le tour en
|
||||
/// cours. Le timeout de tour ([`ASK_AGENT_TIMEOUT`]) borne **le tour lui-même**,
|
||||
/// **pas** cette attente : on lui donne donc son propre plafond, généreux mais fini,
|
||||
/// pour qu'un `ask` ne reste jamais bloqué indéfiniment si la file est longue
|
||||
/// (inanition). À l'expiration, l'`ask` renvoie un timeout typé (réutilise le
|
||||
/// **même** type que le timeout de tour, cf. [`AppError::Process`] via
|
||||
/// [`domain::ports::AgentSessionError::Timeout`]) ; le tour en cours n'est pas
|
||||
/// affecté. Largeur = un tour complet + sa propre file ⇒ on autorise deux tours
|
||||
/// pleins d'attente.
|
||||
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
|
||||
|
||||
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
|
||||
pub struct OrchestratorService {
|
||||
create_agent: Arc<CreateAgentFromScratch>,
|
||||
@ -62,6 +80,25 @@ pub struct OrchestratorService {
|
||||
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
|
||||
/// publication (l'`ask` fonctionne quand même).
|
||||
events: Option<Arc<dyn EventBus>>,
|
||||
/// Verrous de **tour par agent** (A0, cadrage v5 §4) — sérialisation FIFO des
|
||||
/// `ask` vers une **même** cible : « 1 agent = 1 employé, un tour à la fois ».
|
||||
///
|
||||
/// Clé = `AgentId` de la **cible** ; valeur = un `tokio::Mutex` d'unité dont le
|
||||
/// garde est tenu **à travers** le `.await` de `send_blocking` (d'où le mutex
|
||||
/// **async**, pas `std`). La `HashMap` elle-même n'est tenue que le temps du
|
||||
/// get-or-create (jamais à travers un `.await`), donc protégée par un mutex
|
||||
/// **synchrone** `std` — pas de garde gardé en travers d'un point de suspension.
|
||||
///
|
||||
/// Le registre vit **ici** (règle applicative d'orchestration, frontière
|
||||
/// hexagonale, cadrage v5 §5) et **non** dans [`StructuredSessions`] : sérialiser
|
||||
/// les tours est une responsabilité de l'orchestrateur, pas du stockage de
|
||||
/// sessions (SRP) ; le couplage reste minimal. Verrou **par agent** ⇒ deux `ask`
|
||||
/// vers des agents **différents** n'entrent jamais en contention (un map d'`Arc`
|
||||
/// distincts).
|
||||
///
|
||||
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
|
||||
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
|
||||
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
|
||||
}
|
||||
|
||||
/// Outcome of dispatching a command — a short, human-readable success summary the
|
||||
@ -102,9 +139,24 @@ impl OrchestratorService {
|
||||
sessions,
|
||||
structured: None,
|
||||
events: None,
|
||||
ask_locks: StdMutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the per-agent **turn lock**, creating it on first use.
|
||||
///
|
||||
/// Get-or-create under the synchronous map mutex (held only for this lookup,
|
||||
/// never across an `.await`); the returned `Arc<AsyncMutex<()>>` is the lock the
|
||||
/// caller acquires (and holds across `send_blocking`) to serialise turns for
|
||||
/// `agent_id`. Two different agents get two distinct `Arc`s ⇒ no contention.
|
||||
fn ask_lock_for(&self, agent_id: &AgentId) -> Arc<AsyncMutex<()>> {
|
||||
let mut locks = self
|
||||
.ask_locks
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
Arc::clone(locks.entry(*agent_id).or_default())
|
||||
}
|
||||
|
||||
/// Branche le registre des sessions **structurées** (§17.5) pour servir
|
||||
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 :
|
||||
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent
|
||||
@ -202,18 +254,43 @@ impl OrchestratorService {
|
||||
});
|
||||
}
|
||||
OrchestratorVisibility::Visible { node_id } => {
|
||||
let session = self
|
||||
.sessions
|
||||
.rebind_agent_node(&agent_id, node_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"running session {session_id} for agent {name}"
|
||||
))
|
||||
})?;
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("attached agent {name} to cell {}", session.node_id),
|
||||
reply: None,
|
||||
});
|
||||
// R0a — même règle que le garde de `LaunchAgent` (cadrage v5 §3.2,
|
||||
// Trou A) : un `spawn` est un **lancement neuf** (pas de
|
||||
// conversation portée), donc ré-attacher à la **même** cellule
|
||||
// hôte est légitime (rebind de vue), mais viser un **autre** node
|
||||
// pour un agent singleton déjà vivant est un second lancement ⇒
|
||||
// refus `AgentAlreadyRunning`.
|
||||
let host_node = self.sessions.node_for_agent(&agent_id);
|
||||
match ReattachDecision::resolve(Some(node_id), host_node, None) {
|
||||
ReattachDecision::Rebind { node_id } => {
|
||||
let session = self
|
||||
.sessions
|
||||
.rebind_agent_node(&agent_id, node_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"running session {session_id} for agent {name}"
|
||||
))
|
||||
})?;
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!(
|
||||
"attached agent {name} to cell {}",
|
||||
session.node_id
|
||||
),
|
||||
reply: None,
|
||||
});
|
||||
}
|
||||
ReattachDecision::Refuse { node_id } => {
|
||||
return Err(AppError::AgentAlreadyRunning { agent_id, node_id });
|
||||
}
|
||||
// A `Visible` spawn always carries a node, so the decision is
|
||||
// never `Idempotent`; treat it as a same-node rebind for safety.
|
||||
ReattachDecision::Idempotent => {
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("agent {name} already running"),
|
||||
reply: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -287,6 +364,29 @@ impl OrchestratorService {
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
|
||||
|
||||
// Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou
|
||||
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT
|
||||
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte).
|
||||
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque
|
||||
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors.
|
||||
//
|
||||
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ;
|
||||
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock.
|
||||
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas**
|
||||
// l'attente du verrou ; cette attente a son **propre** plafond
|
||||
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition.
|
||||
let lock = self.ask_lock_for(&agent_id);
|
||||
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(_elapsed) => {
|
||||
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) :
|
||||
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
|
||||
return Err(AppError::from(
|
||||
domain::ports::AgentSessionError::Timeout,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct.
|
||||
if let Some(session) = structured.session_for_agent(&agent_id) {
|
||||
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
|
||||
|
||||
Reference in New Issue
Block a user