//! [`OrchestratorService`] — dispatches a validated [`OrchestratorCommand`] to the
//! existing agent/terminal use cases (ARCHITECTURE §14.3).
//!
//! The orchestrator agent never spawns a process itself: IdeA is the single source
//! of truth for the agent lifecycle. This service is the application-layer seam
//! that turns a request into the *same* calls the UI makes:
//!
//! - `spawn_agent`     → [`CreateAgentFromScratch`] (if unknown) then [`LaunchAgent`],
//! - `stop_agent`      → resolve the agent's live session, then [`CloseTerminal`],
//! - `update_agent_context` → [`UpdateAgentContext`].
//!
//! It talks **only** to use cases and ports ([`ProfileStore`], [`TerminalSessions`]):
//! 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::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::{Duration, Instant};

use tokio::sync::Mutex as AsyncMutex;

use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{BackgroundTaskStore, Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath;
use domain::{
    AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskRendezvousLink,
    BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent,
    OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, RendezvousId, RuntimeAgentKey,
    TaskId,
};

use crate::conversation::RecordTurn;
use crate::memory::HarvestMemoryFromTurn;

use crate::agent::{
    drain_with_readiness_and_announcements_outcome, AnnouncementPublisher, CreateAgentFromScratch,
    CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, McpRuntime,
    ReattachDecision, SessionLimitService, TurnOutcome, UpdateAgentContext,
    UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
};
use crate::background::{SpawnBackgroundCommand, SpawnBackgroundCommandInput};
use crate::error::AppError;
use crate::orchestrator::rendezvous::{run_inactivity_watchdog, WatchdogOutcome};
use crate::orchestrator::{
    ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
    ReadMemoryInput, UpdateProjectContext, UpdateProjectContextInput, WriteMemory,
    WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::workstate::{GetLiveStateLean, 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
/// /cols so the spawn never fails on a zero-sized terminal.
const DEFAULT_ROWS: u16 = 24;
/// See [`DEFAULT_ROWS`].
const DEFAULT_COLS: u16 = 80;

fn runtime_key(project: &Project, agent_id: AgentId) -> RuntimeAgentKey {
    RuntimeAgentKey::new(project.id, agent_id)
}

/// Submit defaults for delegated prompts, after applying profile-specific
/// compatibility fallbacks for existing saved profiles.
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
    let delay_ms = profile.submit_delay_ms.or_else(|| {
        matches!(
            profile.structured_adapter,
            Some(domain::profile::StructuredAdapter::Codex)
        )
        .then_some(CODEX_SUBMIT_DELAY_MS)
    });
    SubmitConfig::new(profile.submit_sequence.clone(), delay_ms)
}

fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool {
    match err {
        domain::ports::AgentSessionError::Io(message)
        | domain::ports::AgentSessionError::Decode(message) => {
            message.contains("sans événement Final")
                || message.contains("without a structured final")
                || message.contains("aucun final textuel exploitable")
        }
        domain::ports::AgentSessionError::Start(_) | domain::ports::AgentSessionError::Timeout => {
            false
        }
    }
}

fn bound_task_text(value: &str, max_bytes: usize) -> String {
    if value.len() <= max_bytes {
        return value.to_owned();
    }
    let mut end = max_bytes;
    while !value.is_char_boundary(end) {
        end -= 1;
    }
    value[..end].to_owned()
}

fn resolve_background_cwd(
    project_root: &ProjectPath,
    cwd: Option<String>,
) -> Result<ProjectPath, AppError> {
    let root = Path::new(project_root.as_str());
    let raw = cwd.as_deref().map(str::trim).filter(|s| !s.is_empty());
    let candidate = match raw {
        None => root.to_path_buf(),
        Some(value) => {
            let path = Path::new(value);
            if path.is_absolute() {
                path.to_path_buf()
            } else {
                root.join(path)
            }
        }
    };
    let normalized = normalize_path_no_parent(&candidate)?;
    if !normalized.starts_with(root) {
        return Err(AppError::Invalid(
            "background task cwd must stay under the project root".to_owned(),
        ));
    }
    let Some(path) = normalized.to_str() else {
        return Err(AppError::Invalid(
            "background task cwd is not valid UTF-8".to_owned(),
        ));
    };
    ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
}

fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
    if let Some(link) = &task.rendezvous {
        return Some(domain::RendezvousContext {
            requester_agent_id: link.requester_agent_id,
            target_agent_id: link.target_agent_id,
            conversation_id: link.conversation_id,
        });
    }
    match &task.kind {
        BackgroundTaskKind::HeadlessRendezvous {
            requester_agent_id,
            target_agent_id,
            conversation_id,
            ..
        } => Some(domain::RendezvousContext {
            requester_agent_id: *requester_agent_id,
            target_agent_id: *target_agent_id,
            conversation_id: *conversation_id,
        }),
        _ => None,
    }
}

fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
    let mut out = PathBuf::new();
    for component in path.components() {
        match component {
            Component::Prefix(prefix) => out.push(prefix.as_os_str()),
            Component::RootDir => out.push(component.as_os_str()),
            Component::CurDir => {}
            Component::Normal(part) => out.push(part),
            Component::ParentDir => {
                return Err(AppError::Invalid(
                    "background task cwd must not contain `..`".to_owned(),
                ))
            }
        }
    }
    Ok(out)
}

/// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`).
///
/// A target agent's turn can be long (reasoning + tool use), so the cap is
/// generous; on expiry [`send_blocking`] returns [`domain::ports::AgentSessionError::Timeout`]
/// **without killing the session**, so the requester can retry. Internal and
/// intentionally not yet config-exposed (it may become a per-project setting
/// without changing the contract).
///
/// **Plancher universel FINI (backstop no-reply).** Le vrai signal de fin-de-tour
/// existe désormais (`turn_duration` du transcript via [`domain::ports::TurnWatcher`]
/// ⇒ [`domain::input::InputMediator::turn_ended`], qui réveille l'appelant en ≈ grâce),
/// mais il n'est émis que par Claude. Ce timeout reste donc le **dernier recours**
/// pour toute cible silencieuse qui n'émet pas `turn_duration` (Codex & co) ou qui
/// hang : il ne doit JAMAIS être (quasi-)infini sous peine de wedger l'appelant. 600 s
/// par défaut, **surchargeable par profil** via `turn_timeout_ms`
/// ([`resolve_turn_timeout`]).
const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(600);

/// **Sonde de vivacité** d'une cible de délégation, keyée par son [`AgentId`] : renvoie
/// un **jeton d'activité** monotone non-décroissant (en pratique la taille cumulée du
/// transcript de la cible). Un jeton strictement plus grand entre deux sondes ⇒ la cible
/// **progresse** (vivante et au travail, **même dans un seul long tour** qui n'émet aucun
/// événement intermédiaire — cf. [`rendezvous`](crate::orchestrator::rendezvous)).
/// `None` ⇒ vivacité non observable (cible/transcript introuvable) ⇒ traité comme
/// « pas de progrès ».
///
/// **Pourquoi un port et pas le flux d'événements** : le flux de
/// [`domain::ports::AgentSession::send`] est **batché en fin de tour** (les `ReplyEvent`
/// arrivent tous à la fin, pas en continu), donc inutilisable comme signe de vie *pendant*
/// un long tour. La seule preuve de vie disponible est cette sonde externe (transcript).
/// Closure abstraite (frontière hexagonale) construite à la composition root — la seule
/// couche qui sait dériver le run-dir transcript d'un `AgentId` ; l'application reste pure.
pub type AskLivenessProbe = Arc<
    dyn Fn(
            domain::project::ProjectPath,
            AgentId,
        ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<u64>> + Send>>
        + Send
        + Sync,
>;

/// Plafond **absolu** par défaut du rendez-vous délégué : la fenêtre d'inactivité
/// réarmée ne parque jamais un `ask` au-delà, même contre une cible perpétuellement
/// active. Généreux (4 h) pour qu'un tour unique lourd mais réel finisse bien avant.
/// Surchargeable via `IDEA_ASK_RENDEZVOUS_CEILING_MS` à la composition root.
const ASK_AGENT_CEILING: Duration = crate::orchestrator::rendezvous::DEFAULT_RENDEZVOUS_CEILING;

/// 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.
///
/// FINI (cf. [`ASK_AGENT_TIMEOUT`]) : deux tours pleins d'attente ⇒ 2 × 600 s = 1200 s.
/// Comme la borne de tour, ce plafond reste fini (jamais d'attente quasi-infinie en file).
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(1200);

/// Borne de tour effective (lot 2, timeouts pilotés par profil) : le
/// `turn_timeout_ms` du profil de la cible **quand il existe**, sinon le défaut
/// historique [`ASK_AGENT_TIMEOUT`] (zéro régression pour un profil sans `liveness` /
/// sans `turn_timeout_ms`). Pure et testable sans wiring de service.
#[must_use]
fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
    turn_timeout_ms.map_or(ASK_AGENT_TIMEOUT, |ms| Duration::from_millis(u64::from(ms)))
}

/// Garde RAII de **fin de tour** : ramène la cible `Idle` quoi qu'il arrive (succès,
/// erreur, timeout, **et surtout future abandonné/dropped**) tant qu'elle n'a pas été
/// désarmée.
///
/// Cause racine du blocage `Busy` à vie : l'agent passe `Idle→Busy` dès l'`enqueue`
/// (qui démarre le tour) mais ne revenait `Idle` que sur la **branche succès** du
/// `select!`/`match`. Si le futur `ask_agent` est **dropped** (le demandeur a interrompu
/// son appel), AUCUNE branche ne s'exécute ⇒ l'agent reste `Busy`, et toutes les
/// délégations suivantes s'empilent derrière ce tour fantôme sans jamais être livrées.
///
/// Le garde tient les `Arc` nécessaires pour, à son `Drop`, faire à la fois
/// `cancel_head(agent, ticket)` (retire le ticket fantôme de la FIFO — idempotent et
/// positionnel : no-op si le head a déjà changé) **et** `mark_idle(agent)` (libère la
/// FIFO — idempotent). Sur **succès**, on appelle [`BusyTurnGuard::disarm`] AVANT de
/// retourner : le `mark_idle` « propre » déjà présent reste en place et on évite tout
/// double `cancel_head` d'un ticket déjà résolu.
struct BusyTurnGuard {
    input: Arc<dyn InputMediator>,
    mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
    agent: RuntimeAgentKey,
    ticket: TicketId,
    armed: bool,
}

impl BusyTurnGuard {
    /// Arme le garde juste après l'`enqueue` (qui a démarré le tour ⇒ cible `Busy`).
    fn new(
        input: Arc<dyn InputMediator>,
        mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
        agent: RuntimeAgentKey,
        ticket: TicketId,
    ) -> Self {
        Self {
            input,
            mailbox,
            agent,
            ticket,
            armed: true,
        }
    }

    /// Désarme le garde (branche succès) : le `Drop` devient un no-op. À appeler AVANT
    /// de retourner le contenu, pour préserver le `mark_idle` propre déjà fait et ne pas
    /// re-`cancel_head` un ticket déjà résolu.
    fn disarm(mut self) {
        self.armed = false;
    }
}

impl Drop for BusyTurnGuard {
    fn drop(&mut self) {
        if self.armed {
            // Ordre : retirer le ticket fantôme de la FIFO PUIS libérer le busy state.
            // `cancel_head` est positionnel/idempotent (no-op si le head n'est plus ce
            // ticket) et `mark_idle` est idempotent (no-op si déjà Idle) ⇒ sûr quel que
            // soit le chemin (erreur, timeout, drop).
            self.mailbox.cancel_head(self.agent, self.ticket);
            self.input.mark_idle(self.agent);
            // Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy
            // (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît
            // sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de
            // blocage à diagnostiquer.
            crate::diag!(
                "[rendezvous] busy-guard freed target agent {} (ticket {})",
                self.agent.agent_id,
                self.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).
///
/// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin
/// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5
/// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière.
pub trait McpRuntimeProvider: Send + Sync {
    /// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply).
    /// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement).
    fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime>;
}

/// Fournit le use case [`RecordTurn`] **lié au project root** de la délégation en
/// cours (lot P6b).
///
/// L'[`OrchestratorService`] est **unique et partagé par tous les projets ouverts**
/// (un seul `Arc` au composition root), alors que le log/handoff conversationnel est
/// **par project root** (`<root>/.ideai/conversations/`, comme la mémoire). Les
/// adapters `Fs*` de P6a fixent leur racine à la construction et leur port ne porte
/// pas le root par appel ; on ne peut donc pas figer un `RecordTurn` global. Ce
/// **port** lève la tension : `ask_agent` connaît le `project.root` du tour et demande
/// au provider de matérialiser un `RecordTurn` ciblant le **bon** dossier (les adapters
/// `Fs*` ne font que des jointures de chemin, leur construction est triviale).
///
/// `None` ⇒ aucune persistance (best-effort absente) : zéro régression pour les call
/// sites/tests qui ne le branchent pas. Implémenté dans app-tauri (seul détenteur des
/// adapters `Fs*`).
pub trait RecordTurnProvider: Send + Sync {
    /// Construit le [`RecordTurn`] dont le log/handoff ciblent `root`. Appelé une fois
    /// par checkpoint best-effort ; `None` ⇒ on saute silencieusement la persistance.
    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>>;
}

/// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir
/// l'outil de lecture `idea_workstate_read`.
///
/// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est
/// **unique** pour tous les projets, mais le live-state est **par project root** et le
/// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate`
/// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le
/// **bon** dossier (prune-on-read + snapshot lean).
///
/// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts).
/// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge).
pub trait LiveStateReadProvider: Send + Sync {
    /// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil
    /// `idea_workstate_read` n'est pas servi pour ce root.
    fn live_state_lean_for(&self, root: &ProjectPath) -> Option<Arc<GetLiveStateLean>>;
}

/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
    create_agent: Arc<CreateAgentFromScratch>,
    launch_agent: Arc<LaunchAgent>,
    list_agents: Arc<ListAgents>,
    close_terminal: Arc<CloseTerminal>,
    update_context: Arc<UpdateAgentContext>,
    create_skill: Arc<CreateSkill>,
    profiles: Arc<dyn ProfileStore>,
    sessions: Arc<TerminalSessions>,
    /// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
    /// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
    /// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒
    /// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la
    /// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via
    /// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call
    /// sites/tests legacy restent verts).
    input: Option<Arc<dyn InputMediator>>,
    /// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head`
    /// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que
    /// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`].
    mailbox: Option<Arc<dyn domain::mailbox::AgentMailbox>>,
    /// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement
    /// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes.
    /// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage
    /// par agent sans matérialisation de fil (legacy).
    conversations: Option<Arc<dyn ConversationRegistry>>,
    /// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un
    /// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à
    /// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock.
    wait_for: StdMutex<WaitForGraph>,
    /// Snapshot opérationnel des mêmes arêtes d'attente, exposé au chemin d'arrêt
    /// utilisateur : si A est stoppé pendant qu'il attend B, IdeA stoppe aussi B.
    /// Séparé de [`WaitForGraph`] qui reste un objet domaine minimal de détection de
    /// cycle, sans API de traversal.
    active_waits: StdMutex<Vec<(AgentId, AgentId)>>,
    /// Composite business rendezvous currently driven by a target agent. Used to
    /// structurally attach `idea_run_in_background` tasks launched during
    /// `idea_ask_agent` without parsing the model's textual `Final`.
    active_rendezvous: StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
    /// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un
    /// `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<()>>>>,
    /// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration
    /// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask`
    /// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis
    /// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`),
    /// donc zéro régression pour les call sites/tests qui ne le branchent pas.
    mcp_runtime_provider: Option<Arc<dyn McpRuntimeProvider>>,
    /// FileGuard-mediated context/memory use cases (cadrage C7). Injected via
    /// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*`
    /// renvoient une erreur typée (call sites/tests legacy restent verts).
    context_guard: Option<Arc<ContextGuardUseCases>>,
    /// Provider du use case [`RecordTurn`] **par project root** (lot P6b), pour
    /// persister **best-effort** le Prompt et la Response de chaque paire déléguée dans
    /// le bon `conversationId`. Injecté avec son horloge via [`Self::with_record_turn`] ;
    /// `None` ⇒ aucune persistance (zéro régression pour les call sites/tests legacy).
    /// Un échec de persistance ne transforme **jamais** un succès de délégation en erreur.
    record_turn: Option<Arc<dyn RecordTurnProvider>>,
    /// Horloge millis (port [`Clock`]) pour estampiller `at_ms` des tours persistés —
    /// injectée avec [`Self::record_turn`]. La couche `application` reste **pure** : pas
    /// de `SystemTime::now()` brut ici, le temps vient du port injecté au composition root.
    clock: Option<Arc<dyn Clock>>,
    /// Registre des **sessions IA structurées** vivantes (§17.5), pour router un `ask`
    /// vers une cible à `structured_adapter` directement sur sa session
    /// ([`drain_with_readiness`](crate::agent::drain_with_readiness)) — le `Final`
    /// déterministe réveille le `pending` **et** marque l'agent `Idle` (chantier
    /// readiness/heartbeat, lot 1, fix du blocage `Busy`). Injecté via
    /// [`Self::with_structured`] ; `None` ⇒ on conserve **uniquement** le chemin
    /// PTY/mailbox legacy (zéro régression pour les call sites/tests qui ne le branchent
    /// pas).
    structured: Option<Arc<StructuredSessions>>,
    /// Harvester d'auto-mémoire (Lot E1), branché **après** le checkpoint Response
    /// best-effort d'un `ask` réussi : parse les blocs ` ```idea-memory ` de la
    /// réponse et persiste les notes valides via le `MemoryStore`. Injecté via
    /// [`Self::with_memory_harvest`] ; `None` ⇒ aucun harvest (zéro régression). Un
    /// échec de harvest ne transforme **jamais** un succès de délégation en erreur.
    memory_harvest: Option<Arc<HarvestMemoryFromTurn>>,
    /// Use case de lecture d'un skill **par nom** (`idea_skill_read`, feature
    /// « skills à la MCP »). Compose le port `SkillStore` existant (aucun nouveau
    /// 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>>,
    /// Provider de lecture du **live-state** (lot LS4) pour l'outil
    /// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture
    /// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent.
    /// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur
    /// typée « not configured » (call sites/tests legacy restent verts).
    live_state_read: Option<Arc<dyn LiveStateReadProvider>>,
    /// **Sonde de vivacité** de la cible (signe de vie) pilotant la fenêtre d'inactivité
    /// du rendez-vous délégué (cf. [`AskLivenessProbe`] et
    /// [`rendezvous`](crate::orchestrator::rendezvous)). Injectée via
    /// [`Self::with_ask_liveness_probe`]. `None` ⇒ la borne de tour reste un **timeout
    /// plat** (fallback fenêtre plate = comportement historique, zéro régression).
    ask_liveness_probe: Option<AskLivenessProbe>,
    /// Plafond **absolu** du rendez-vous délégué (cf. [`ASK_AGENT_CEILING`]) : la fenêtre
    /// d'inactivité réarmée ne parque jamais un `ask` au-delà. Surchargeable via
    /// [`Self::with_ask_ceiling`] (la composition root résout l'override d'env) ; les
    /// tests le rétrécissent à quelques ms. Défaut = 4 h.
    ask_ceiling: Duration,
    /// Store durable des tâches de fond de 1re classe. B6 l'utilise pour tracer un
    /// `idea_ask_agent` comme [`BackgroundTaskKind::HeadlessRendezvous`]. `None` ⇒
    /// comportement historique sans persistance de tâche (call sites non câblés).
    background_tasks: Option<Arc<dyn BackgroundTaskStore>>,
    /// Service applicatif des limites de session, injecté depuis la composition root.
    /// `None` conserve les call sites/tests legacy ; les chemins conscients des limites
    /// publient alors seulement leur erreur de tour historique.
    session_limits: Option<Arc<SessionLimitService>>,
    /// Use case B8 qui produit une tâche de fond command-backed depuis la surface
    /// agent MCP `idea_run_in_background`. `None` ⇒ outil non configuré.
    spawn_background_command: Option<Arc<SpawnBackgroundCommand>>,
}

/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
/// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la
/// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`).
pub struct ContextGuardUseCases {
    /// Lecture d'un contexte `.md` IdeA sous read-lease.
    pub read_context: Arc<ReadContext>,
    /// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
    pub propose_context: Arc<ProposeContext>,
    /// Écriture directe stricte du contexte projet global.
    pub update_project_context: Arc<UpdateProjectContext>,
    /// Lecture mémoire sous read-lease.
    pub read_memory: Arc<ReadMemory>,
    /// Écriture mémoire sous write-lease.
    pub write_memory: Arc<WriteMemory>,
}

/// Outcome of dispatching a command — a short, human-readable success summary the
/// infrastructure adapter folds into the JSON response file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrchestratorOutcome {
    /// One-line description of what IdeA did (e.g. `"launched agent dev-backend"`).
    pub detail: String,
    /// The target agent's **reply content** for a synchronous `agent.message`
    /// (`AskAgent`, §17.4). `Some(content)` carries the turn's `Final`; every other
    /// command leaves it `None`. Additive field ⇒ existing call sites/tests are
    /// untouched (they only read [`Self::detail`]).
    pub reply: Option<String>,
}

#[derive(Debug, Clone)]
struct ActiveRendezvous {
    link: BackgroundTaskRendezvousLink,
    background_tasks: Vec<TaskId>,
}

impl OrchestratorService {
    /// Builds the service from the use cases and ports it dispatches to.
    #[must_use]
    #[allow(clippy::too_many_arguments)]
    pub fn new(
        create_agent: Arc<CreateAgentFromScratch>,
        launch_agent: Arc<LaunchAgent>,
        list_agents: Arc<ListAgents>,
        close_terminal: Arc<CloseTerminal>,
        update_context: Arc<UpdateAgentContext>,
        create_skill: Arc<CreateSkill>,
        profiles: Arc<dyn ProfileStore>,
        sessions: Arc<TerminalSessions>,
    ) -> Self {
        Self {
            create_agent,
            launch_agent,
            list_agents,
            close_terminal,
            update_context,
            create_skill,
            profiles,
            sessions,
            input: None,
            mailbox: None,
            conversations: None,
            wait_for: StdMutex::new(WaitForGraph::new()),
            active_waits: StdMutex::new(Vec::new()),
            active_rendezvous: StdMutex::new(HashMap::new()),
            events: None,
            ask_locks: StdMutex::new(HashMap::new()),
            mcp_runtime_provider: None,
            context_guard: None,
            record_turn: None,
            clock: None,
            structured: None,
            memory_harvest: None,
            read_skill: None,
            live_state: None,
            live_state_read: None,
            ask_liveness_probe: None,
            ask_ceiling: ASK_AGENT_CEILING,
            background_tasks: None,
            session_limits: None,
            spawn_background_command: None,
        }
    }

    /// Branche le service de reprise automatique des limites de session.
    #[must_use]
    pub fn with_session_limits(mut self, service: Arc<SessionLimitService>) -> Self {
        self.session_limits = Some(service);
        self
    }

    /// Branche le producteur B8 command-backed pour `idea_run_in_background`.
    #[must_use]
    pub fn with_spawn_background_command(mut self, spawn: Arc<SpawnBackgroundCommand>) -> Self {
        self.spawn_background_command = Some(spawn);
        self
    }

    /// Branche le store des tâches de fond pour tracer les rendez-vous inter-agents
    /// comme [`BackgroundTaskKind::HeadlessRendezvous`] (B6).
    ///
    /// Le `clock` fournit les timestamps métier de la tâche. Il réutilise le champ
    /// horloge applicatif déjà existant ; si un provider de conversation log l'avait
    /// déjà posé, la même horloge reste partagée.
    #[must_use]
    pub fn with_background_tasks(
        mut self,
        store: Arc<dyn BackgroundTaskStore>,
        clock: Arc<dyn Clock>,
    ) -> Self {
        self.background_tasks = Some(store);
        self.clock = Some(clock);
        self
    }

    /// Branche la **sonde de vivacité** (signe de vie) du rendez-vous délégué : la borne
    /// de tour devient une **fenêtre d'inactivité** réarmée à chaque progrès observé,
    /// sous le plafond [`Self::with_ask_ceiling`]. Builder additif (signature de
    /// [`Self::new`] inchangée) ; sans sonde, la borne reste un timeout plat (fallback,
    /// zéro régression). Cf. [`AskLivenessProbe`].
    #[must_use]
    pub fn with_ask_liveness_probe(mut self, probe: AskLivenessProbe) -> Self {
        self.ask_liveness_probe = Some(probe);
        self
    }

    /// Surcharge le **plafond absolu** du rendez-vous délégué (défaut [`ASK_AGENT_CEILING`]
    /// = 4 h) au-delà duquel la fenêtre d'inactivité réarmée ne parque jamais un `ask`.
    /// La composition root y applique l'override d'env ; les tests le rétrécissent.
    /// Builder additif.
    #[must_use]
    pub fn with_ask_ceiling(mut self, ceiling: Duration) -> Self {
        self.ask_ceiling = ceiling;
        self
    }

    /// Branche le use case [`ReadSkill`] (`skill.read`/`idea_skill_read`). Builder
    /// additif (signature de [`Self::new`] inchangée) ; `None` ⇒ la commande
    /// renvoie une erreur typée « not configured ».
    #[must_use]
    pub fn with_read_skill(mut self, read_skill: Arc<ReadSkill>) -> Self {
        self.read_skill = Some(read_skill);
        self
    }

    /// Branche le registre des [`StructuredSessions`] (§17.5) pour router un `ask`
    /// vers une cible **structurée** sur sa propre session. Builder additif (signature
    /// de [`Self::new`] inchangée) ; `None` ⇒ chemin PTY/mailbox legacy uniquement.
    #[must_use]
    pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
        self.structured = Some(structured);
        self
    }

    /// Branche les use cases C7 (`context.*`/`memory.*`) sous
    /// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
    /// inchangée).
    #[must_use]
    pub fn with_context_guard(mut self, guard: Arc<ContextGuardUseCases>) -> Self {
        self.context_guard = Some(guard);
        self
    }

    /// 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())
    }

    fn active_rendezvous_link(
        &self,
        agent: RuntimeAgentKey,
    ) -> Option<BackgroundTaskRendezvousLink> {
        self.active_rendezvous
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(&agent)
            .map(|r| r.link.clone())
    }

    fn attach_background_to_active_rendezvous(
        &self,
        agent: RuntimeAgentKey,
        rendezvous_id: RendezvousId,
        task_id: TaskId,
    ) {
        if let Some(active) = self
            .active_rendezvous
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get_mut(&agent)
            .filter(|active| active.link.rendezvous_id == rendezvous_id)
        {
            active.background_tasks.push(task_id);
        }
    }

    fn active_rendezvous_has_background_tasks(
        &self,
        agent: RuntimeAgentKey,
        rendezvous_id: RendezvousId,
    ) -> bool {
        self.active_rendezvous
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(&agent)
            .filter(|active| active.link.rendezvous_id == rendezvous_id)
            .is_some_and(|active| !active.background_tasks.is_empty())
    }

    /// Returns the transitive set of agents currently waited on by `agent`.
    ///
    /// Used by user-driven cancellation: stopping A while A waits on B should also
    /// stop B, and then any agent B itself waits on. The snapshot is best-effort and
    /// lock-bounded; callers perform the actual interruption/stop outside the mutex.
    #[must_use]
    pub fn active_wait_dependencies(&self, agent: AgentId) -> Vec<AgentId> {
        let edges = self
            .active_waits
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone();
        Self::active_wait_dependencies_from_edges(&edges, agent)
    }

    /// Returns the transitive dependencies of `agent` in an active wait-edge snapshot.
    #[must_use]
    fn active_wait_dependencies_from_edges(
        edges: &[(AgentId, AgentId)],
        agent: AgentId,
    ) -> Vec<AgentId> {
        let mut out = Vec::new();
        let mut stack: Vec<AgentId> = edges
            .iter()
            .filter_map(|(from, to)| (*from == agent).then_some(*to))
            .collect();
        while let Some(next) = stack.pop() {
            if out.contains(&next) {
                continue;
            }
            out.push(next);
            stack.extend(
                edges
                    .iter()
                    .filter_map(|(from, to)| (*from == next).then_some(*to)),
            );
        }
        out
    }

    /// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir
    /// `agent.message`/[`OrchestratorCommand::AskAgent`] et
    /// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de
    /// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que
    /// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/
    /// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de
    /// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts).
    #[must_use]
    pub fn with_input_mediator(
        mut self,
        input: Arc<dyn InputMediator>,
        mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
    ) -> Self {
        self.input = Some(input);
        self.mailbox = Some(mailbox);
        self
    }

    fn now_ms(&self) -> u64 {
        self.clock
            .as_ref()
            .map_or(0, |clock| clock.now_millis().max(0) as u64)
    }

    #[allow(clippy::too_many_arguments)]
    async fn start_rendezvous_task(
        &self,
        project: &Project,
        owner_agent_id: AgentId,
        link: BackgroundTaskRendezvousLink,
    ) -> Result<Option<TaskId>, AppError> {
        let Some(store) = &self.background_tasks else {
            return Ok(None);
        };
        let task_id = TaskId::new_random();
        let now = self.now_ms();
        let task = BackgroundTask::new(
            task_id,
            project.id,
            owner_agent_id,
            BackgroundTaskKind::HeadlessRendezvous {
                requester_agent_id: link.requester_agent_id,
                target_agent_id: link.target_agent_id,
                ticket_id: link.ticket_id,
                conversation_id: link.conversation_id,
            },
            BackgroundTaskWakePolicy::RecordOnly,
            now,
            None,
        )
        .map_err(|err| AppError::Internal(err.to_string()))?
        .with_rendezvous(link)
        .transition(BackgroundTaskState::Running, now)
        .map_err(|err| AppError::Internal(err.to_string()))?;
        store
            .create(&task)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?;
        if let Some(events) = &self.events {
            events.publish(DomainEvent::BackgroundTaskStarted {
                project_id: project.id,
                task_id,
                owner_agent_id,
            });
        }
        Ok(Some(task_id))
    }

    async fn complete_rendezvous_task_success(
        &self,
        project: &Project,
        owner_agent_id: AgentId,
        task_id: Option<TaskId>,
        reply: &str,
    ) -> Result<(), AppError> {
        let result = BackgroundTaskResult::Success {
            finished_at_ms: self.now_ms(),
            exit_code: None,
            summary: "Headless rendezvous completed with Final".to_owned(),
            stdout_tail: Some(bound_task_text(
                reply,
                domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
            )),
            stderr_tail: None,
        };
        self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
            .await
    }

    async fn mark_rendezvous_task_waiting(&self, task_id: Option<TaskId>) -> Result<(), AppError> {
        let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else {
            return Ok(());
        };
        let task = store
            .get(task_id)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?
            .ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?;
        if task.is_terminal() || task.state == BackgroundTaskState::Waiting {
            return Ok(());
        }
        let waiting = task
            .transition(BackgroundTaskState::Waiting, self.now_ms())
            .map_err(|err| AppError::Internal(err.to_string()))?;
        store
            .save(&waiting)
            .await
            .map_err(|err| AppError::Store(err.to_string()))
    }

    async fn complete_rendezvous_task_failure(
        &self,
        project: &Project,
        owner_agent_id: AgentId,
        task_id: Option<TaskId>,
        error: impl Into<String>,
    ) -> Result<(), AppError> {
        let result = BackgroundTaskResult::Failure {
            finished_at_ms: self.now_ms(),
            exit_code: None,
            error: bound_task_text(&error.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES),
            stdout_tail: None,
            stderr_tail: None,
        };
        self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
            .await
    }

    async fn complete_rendezvous_task_cancelled(
        &self,
        project: &Project,
        owner_agent_id: AgentId,
        task_id: Option<TaskId>,
        reason: impl Into<String>,
    ) -> Result<(), AppError> {
        let result = BackgroundTaskResult::Cancelled {
            finished_at_ms: self.now_ms(),
            reason: bound_task_text(&reason.into(), domain::BACKGROUND_TASK_TEXT_MAX_BYTES),
        };
        self.finish_rendezvous_task(project, owner_agent_id, task_id, result)
            .await
    }

    async fn finish_rendezvous_task(
        &self,
        project: &Project,
        owner_agent_id: AgentId,
        task_id: Option<TaskId>,
        result: BackgroundTaskResult,
    ) -> Result<(), AppError> {
        let (Some(store), Some(task_id)) = (&self.background_tasks, task_id) else {
            return Ok(());
        };
        let task = store
            .get(task_id)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?
            .ok_or_else(|| AppError::Store(format!("background task {task_id} not found")))?;
        if task.is_terminal() {
            return Ok(());
        }
        let rendezvous = rendezvous_context_for_task(&task);
        let event = match &result {
            BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
                project_id: project.id,
                task_id,
                owner_agent_id,
                rendezvous,
            },
            BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
                project_id: project.id,
                task_id,
                owner_agent_id,
                rendezvous,
            },
            BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
                DomainEvent::BackgroundTaskCancelled {
                    project_id: project.id,
                    task_id,
                    owner_agent_id,
                    rendezvous,
                }
            }
        };
        let completed = task
            .complete(result)
            .map_err(|err| AppError::Internal(err.to_string()))?;
        store
            .save(&completed)
            .await
            .map_err(|err| AppError::Store(err.to_string()))?;
        if let Some(events) = &self.events {
            events.publish(event);
        }
        Ok(())
    }

    /// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre
    /// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes.
    /// Builder additif (signature de [`Self::new`] inchangée).
    #[must_use]
    pub fn with_conversations(mut self, conversations: Arc<dyn ConversationRegistry>) -> Self {
        self.conversations = Some(conversations);
        self
    }

    /// Branche l'[`EventBus`] pour publier [`DomainEvent::AgentReplied`] après un
    /// `ask` réussi (§17.4). Builder additif (cf. [`Self::with_structured`]).
    #[must_use]
    pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
        self.events = Some(events);
        self
    }

    /// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements
    /// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle**
    /// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP
    /// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout).
    /// Builder additif : signature de [`Self::new`] **inchangée**.
    #[must_use]
    pub fn with_mcp_runtime_provider(mut self, provider: Arc<dyn McpRuntimeProvider>) -> Self {
        self.mcp_runtime_provider = Some(provider);
        self
    }

    /// Branche le provider de [`RecordTurn`] **par project root** + son horloge (lot
    /// P6b) pour persister **best-effort** le Prompt et la Response de chaque paire
    /// déléguée. Builder additif : signature de [`Self::new`] **inchangée** (les
    /// tests/call sites legacy restent verts ; `None` ⇒ aucune persistance, donc aucune
    /// régression). Un échec de persistance ne casse jamais la délégation live.
    #[must_use]
    pub fn with_record_turn(
        mut self,
        record_turn: Arc<dyn RecordTurnProvider>,
        clock: Arc<dyn Clock>,
    ) -> Self {
        self.record_turn = Some(record_turn);
        self.clock = Some(clock);
        self
    }

    /// Branche le harvester d'auto-mémoire (Lot E1), appelé **après** le checkpoint
    /// `RecordTurn` de la réponse d'un `ask` réussi. `None` (défaut) ⇒ aucun harvest.
    /// Le `MemoryStore` prend le `root` par appel, donc un seul use case sert tous
    /// les projets (pas de provider par root nécessaire, contrairement au handoff).
    #[must_use]
    pub fn with_memory_harvest(mut self, harvester: Arc<HarvestMemoryFromTurn>) -> Self {
        self.memory_harvest = Some(harvester);
        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
    }

    /// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil
    /// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ;
    /// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car
    /// le live-state est par project root et le port fixe son root à la construction.
    #[must_use]
    pub fn with_live_state_read(mut self, provider: Arc<dyn LiveStateReadProvider>) -> Self {
        self.live_state_read = Some(provider);
        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
    /// ne rend pas de [`RecordTurn`] pour ce root, ou quand l'`append`/`save` échoue : la
    /// persistance ne doit **jamais** transformer un succès de délégation en erreur, ni
    /// paniquer (contrat P6b). N'ajoute pas de latence inutile (un seul `await` borné par
    /// les adapters `Fs*`, déjà sérialisés par le verrou de tour de la cible).
    async fn record_turn_best_effort(
        &self,
        root: &ProjectPath,
        conversation: domain::conversation::ConversationId,
        source: InputSource,
        role: TurnRole,
        text: String,
    ) {
        let (Some(provider), Some(clock)) = (&self.record_turn, &self.clock) else {
            return;
        };
        let Some(record) = provider.record_turn_for(root) else {
            return;
        };
        let at_ms = u64::try_from(clock.now_millis()).unwrap_or(0);
        let turn = ConversationTurn::new(
            TurnId::new_random(),
            conversation,
            at_ms,
            source,
            role,
            text,
        );
        // Best-effort : un échec de persistance est avalé (le contrat P6b interdit qu'il
        // remonte). Pas de framework de log dans `application` ; on reste cohérent avec
        // les autres effets best-effort du service (cf. publication `AgentReplied`).
        let _ = record.record(conversation, turn).await;
    }

    /// Récolte **best-effort** les notes d'auto-mémoire (Lot E1) d'une réponse.
    ///
    /// No-op silencieux quand le harvester n'est pas câblé. Ne parse **que** le texte
    /// du tour courant (jamais le log complet) et n'agit que sur un `Response`. Tout
    /// échec parse/store reste dans l'`outcome` (jamais propagé) : un succès de
    /// délégation n'est **jamais** transformé en erreur. Branché **après** le
    /// checkpoint `RecordTurn` de la réponse (append/handoff déjà effectués).
    async fn harvest_memory_best_effort(&self, root: &ProjectPath, text: &str) {
        let Some(harvester) = &self.memory_harvest else {
            return;
        };
        let outcome = harvester.harvest(root, TurnRole::Response, text).await;
        if !outcome.saved.is_empty() || !outcome.errors.is_empty() {
            crate::diag!(
                "[harvest] auto-memory: found={} saved={} skipped={} errors={}",
                outcome.found,
                outcome.saved.len(),
                outcome.skipped,
                outcome.errors.len(),
            );
        }
    }

    /// Dispatches a validated command against `project`.
    ///
    /// # Errors
    /// Propagates the underlying use-case [`AppError`] (e.g. unknown profile,
    /// unknown agent, PTY failure). For `spawn_agent` a *known* agent is launched
    /// directly; an *unknown* one is created from scratch first.
    pub async fn dispatch(
        &self,
        project: &Project,
        command: OrchestratorCommand,
    ) -> Result<OrchestratorOutcome, AppError> {
        match command {
            OrchestratorCommand::SpawnAgent {
                name,
                profile,
                context,
                visibility,
            } => {
                self.spawn_agent(project, name, profile, context, visibility)
                    .await
            }
            OrchestratorCommand::AskAgent {
                target,
                task,
                requester,
            } => self.ask_agent(project, target, task, requester).await,
            OrchestratorCommand::Reply {
                from,
                ticket,
                result,
            } => self.reply(project, from, ticket, result),
            OrchestratorCommand::ListAgents => self.list_agents(project).await,
            OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
            OrchestratorCommand::UpdateAgentContext { name, context } => {
                self.update_agent_context(project, name, context).await
            }
            OrchestratorCommand::CreateSkill {
                name,
                content,
                scope,
            } => self.create_skill(project, name, content, scope).await,
            OrchestratorCommand::ReadContext { target, requester } => {
                self.read_context(project, target, requester).await
            }
            OrchestratorCommand::ProposeContext {
                target,
                content,
                requester,
            } => {
                self.propose_context(project, target, content, requester)
                    .await
            }
            OrchestratorCommand::UpdateProjectContext {
                content,
                if_match,
                requester,
            } => {
                self.update_project_context(project, content, if_match, requester)
                    .await
            }
            OrchestratorCommand::ReadMemory { slug, requester } => {
                self.read_memory(project, slug, requester).await
            }
            OrchestratorCommand::ReadSkill { name, requester } => {
                self.read_skill(project, name, requester).await
            }
            OrchestratorCommand::WriteMemory {
                slug,
                content,
                requester,
            } => self.write_memory(project, slug, content, requester).await,
            OrchestratorCommand::ReadWorkState { requester } => {
                self.read_workstate(project, requester).await
            }
            OrchestratorCommand::SetWorkState {
                agent,
                status,
                intent,
                progress,
                ticket,
                last_delegation,
            } => {
                self.set_workstate(
                    project,
                    agent,
                    status,
                    intent,
                    progress,
                    ticket,
                    last_delegation,
                )
                .await
            }
            OrchestratorCommand::RunInBackground {
                owner,
                label,
                command,
                args,
                cwd,
                deadline_ms,
            } => {
                self.run_in_background(project, owner, label, command, args, cwd, deadline_ms)
                    .await
            }
        }
    }

    async fn run_in_background(
        &self,
        project: &Project,
        owner: AgentId,
        label: String,
        command: String,
        args: Vec<String>,
        cwd: Option<String>,
        deadline_ms: Option<u64>,
    ) -> Result<OrchestratorOutcome, AppError> {
        let spawn = self.spawn_background_command.as_ref().ok_or_else(|| {
            AppError::Invalid("background command runner is not configured".to_owned())
        })?;
        let cwd = resolve_background_cwd(&project.root, cwd)?;
        let agent_key = runtime_key(project, owner);
        let rendezvous = self.active_rendezvous_link(agent_key);
        let output = spawn
            .execute(SpawnBackgroundCommandInput {
                project_id: project.id,
                owner_agent_id: owner,
                label,
                command: domain::ports::SpawnSpec {
                    command,
                    args,
                    cwd,
                    env: Vec::new(),
                    context_plan: None,
                    sandbox: None,
                },
                wake_policy: BackgroundTaskWakePolicy::WakeOwner,
                rendezvous: rendezvous.clone(),
                deadline_ms,
            })
            .await?;
        if let Some(link) = rendezvous {
            self.attach_background_to_active_rendezvous(
                agent_key,
                link.rendezvous_id,
                output.task.id,
            );
        }
        let state = match output.task.state {
            BackgroundTaskState::Queued => "Queued",
            BackgroundTaskState::Running => "Running",
            BackgroundTaskState::Waiting => "Waiting",
            BackgroundTaskState::Completed => "Completed",
            BackgroundTaskState::Failed => "Failed",
            BackgroundTaskState::Cancelled => "Cancelled",
            BackgroundTaskState::Expired => "Expired",
        };
        Ok(OrchestratorOutcome {
            detail: serde_json::json!({
                "taskId": output.task.id.to_string(),
                "state": state,
            })
            .to_string(),
            reply: None,
        })
    }

    /// Returns the injected C7 use cases, or a typed error when unwired.
    fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
        self.context_guard.as_deref().ok_or_else(|| {
            AppError::Invalid("FileGuard context/memory tools are not configured".to_owned())
        })
    }

    /// `context.read` → reads an IdeA-owned context under a shared read-lease; the
    /// body is returned inline in the outcome's `reply`.
    async fn read_context(
        &self,
        project: &Project,
        target: Option<String>,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let out = self
            .require_context_guard()?
            .read_context
            .execute(ReadContextInput {
                project: project.clone(),
                target: target.clone(),
                requester,
            })
            .await?;
        let mut text = out.content.into_string();
        if let Some(version) = &out.version {
            text.push_str(&format!("\n\n<!-- idea-context-version: {version} -->"));
        }
        Ok(OrchestratorOutcome {
            detail: format!("read {} context", target.as_deref().unwrap_or("project")),
            reply: Some(text),
        })
    }

    /// `context.propose` → direct write (agent ctx / orchestrator on global) or a
    /// materialised proposal (non-orchestrator on global).
    async fn propose_context(
        &self,
        project: &Project,
        target: Option<String>,
        content: String,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let outcome = self
            .require_context_guard()?
            .propose_context
            .execute(ProposeContextInput {
                project: project.clone(),
                target: target.clone(),
                content,
                requester,
            })
            .await?;
        let detail = match outcome {
            ProposeOutcome::Written => {
                format!("wrote {} context", target.as_deref().unwrap_or("project"))
            }
            ProposeOutcome::Proposed { path } => {
                format!("filed proposal for project context at {path}")
            }
        };
        Ok(OrchestratorOutcome {
            detail,
            reply: None,
        })
    }

    /// `context.update` → strict direct write of the global project context.
    async fn update_project_context(
        &self,
        project: &Project,
        content: String,
        if_match: Option<String>,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let out = self
            .require_context_guard()?
            .update_project_context
            .execute(UpdateProjectContextInput {
                project: project.clone(),
                content,
                if_match,
                requester,
            })
            .await?;

        Ok(OrchestratorOutcome {
            detail: format!("wrote project context (version {})", out.new_version),
            reply: None,
        })
    }

    /// `memory.read` → reads a note (or the index) under a shared read-lease; the
    /// content is returned inline in the outcome's `reply`.
    async fn read_memory(
        &self,
        project: &Project,
        slug: Option<String>,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let content = self
            .require_context_guard()?
            .read_memory
            .execute(ReadMemoryInput {
                project: project.clone(),
                slug: slug.clone(),
                requester,
            })
            .await?;
        Ok(OrchestratorOutcome {
            detail: format!("read memory {}", slug.as_deref().unwrap_or("index")),
            reply: Some(content),
        })
    }

    /// `skill.read` → resolves a skill by name (project scope first, then global)
    /// and returns its Markdown body inline in the outcome's `reply`. Read-only:
    /// `requester` is accepted for symmetry with the other read tools but skill
    /// reads take no [`domain::fileguard::FileGuard`] lease.
    async fn read_skill(
        &self,
        project: &Project,
        name: String,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let read_skill = self.read_skill.as_deref().ok_or_else(|| {
            AppError::Invalid("the idea_skill_read tool is not configured".to_owned())
        })?;
        let md = read_skill
            .execute(ReadSkillInput {
                name: name.clone(),
                project: project.clone(),
                requester,
            })
            .await?;
        Ok(OrchestratorOutcome {
            detail: format!("read skill {name}"),
            reply: Some(md.into_string()),
        })
    }

    /// `workstate.read` / `idea_workstate_read` → the project's agent **live-state**
    /// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display
    /// name, returned inline as a JSON array. Entries whose agent is no longer in the
    /// manifest are dropped; `progress` is **never** exposed (the lean DTO has none).
    ///
    /// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`.
    async fn read_workstate(
        &self,
        project: &Project,
        _requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        let provider = self
            .live_state_read
            .as_deref()
            .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
        let getter = provider
            .live_state_lean_for(&project.root)
            .ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
        let lean = getter.execute().await?;

        // Cross with the manifest to enrich each row with the agent's display name and
        // to drop rows whose agent left the manifest (boundary preserved).
        let listed = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await?;
        let names: HashMap<AgentId, String> =
            listed.agents.into_iter().map(|a| (a.id, a.name)).collect();

        let rows: Vec<serde_json::Value> = lean
            .entries
            .into_iter()
            .filter_map(|entry| {
                let name = names.get(&entry.agent_id)?;
                let mut row = serde_json::json!({
                    "agent": name,
                    "status": entry.status,
                    "intent": entry.intent,
                });
                if let Some(ticket) = entry.ticket {
                    row["ticket"] = serde_json::json!(ticket);
                }
                if let Some(last) = entry.last_delegation {
                    row["lastDelegation"] = serde_json::json!(last);
                }
                Some(row)
            })
            .collect();

        let reply = serde_json::to_string(&rows)
            .map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?;
        Ok(OrchestratorOutcome {
            detail: format!("read work-state ({} agent rows)", rows.len()),
            reply: Some(reply),
        })
    }

    /// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current
    /// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`),
    /// so an agent only ever writes its own row. Reuses the LS3 write provider
    /// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and
    /// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`).
    /// **ACK only** (no inline reply).
    #[allow(clippy::too_many_arguments)]
    async fn set_workstate(
        &self,
        project: &Project,
        agent: AgentId,
        status: domain::live_state::WorkStatus,
        intent: Option<String>,
        progress: Option<String>,
        ticket: Option<TicketId>,
        last_delegation: Option<TicketId>,
    ) -> Result<OrchestratorOutcome, AppError> {
        let provider = self
            .live_state
            .as_deref()
            .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
        let update = provider
            .live_state_for(&project.root)
            .ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
        update
            .execute(UpdateLiveStateInput {
                agent_id: agent,
                ticket,
                intent: intent.unwrap_or_default(),
                status,
                progress,
                last_delegation,
            })
            .await?;
        Ok(OrchestratorOutcome {
            detail: format!("set work-state for agent {agent}"),
            reply: None,
        })
    }

    /// `memory.write` → writes a note under an exclusive write-lease.
    async fn write_memory(
        &self,
        project: &Project,
        slug: String,
        content: String,
        requester: ConversationParty,
    ) -> Result<OrchestratorOutcome, AppError> {
        self.require_context_guard()?
            .write_memory
            .execute(WriteMemoryInput {
                project: project.clone(),
                slug: slug.clone(),
                content,
                requester,
            })
            .await?;
        Ok(OrchestratorOutcome {
            detail: format!("wrote memory {slug}"),
            reply: None,
        })
    }

    /// `spawn_agent`: create the agent if the manifest doesn't already hold one by
    /// that name, then launch it (which publishes `AgentLaunched` → the UI opens a
    /// cell + the Agents tab).
    async fn spawn_agent(
        &self,
        project: &Project,
        name: String,
        profile: Option<String>,
        context: Option<String>,
        visibility: OrchestratorVisibility,
    ) -> Result<OrchestratorOutcome, AppError> {
        let existing = self.find_agent_id_by_name(project, &name).await?;

        let agent_id = match existing {
            Some(id) => id,
            None => {
                let profile = profile.as_deref().ok_or_else(|| {
                    AppError::Invalid("profile is required to create an agent".to_owned())
                })?;
                let profile_id = self.resolve_profile(profile).await?;
                let created = self
                    .create_agent
                    .execute(CreateAgentInput {
                        project: project.clone(),
                        name: name.clone(),
                        profile_id,
                        initial_content: context,
                    })
                    .await?;
                created.agent.id
            }
        };

        if let Some(session_id) = self
            .sessions
            .session_for_agent_in_project(project.id, &agent_id)
        {
            match visibility {
                OrchestratorVisibility::Background => {
                    return Ok(OrchestratorOutcome {
                        detail: format!("agent {name} already running in background"),
                        reply: None,
                    });
                }
                OrchestratorVisibility::Visible { node_id } => {
                    // 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_in_project(project.id, &agent_id);
                    match ReattachDecision::resolve(Some(node_id), host_node, None) {
                        ReattachDecision::Rebind { node_id } => {
                            let session = self
                                .sessions
                                .rebind_agent_node_in_project(project.id, &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,
                            });
                        }
                    }
                }
            }
        }

        let node_id = match visibility {
            OrchestratorVisibility::Background => None,
            OrchestratorVisibility::Visible { node_id } => Some(node_id),
        };

        self.launch_agent
            .execute(LaunchAgentInput {
                project: project.clone(),
                agent_id,
                rows: DEFAULT_ROWS,
                cols: DEFAULT_COLS,
                node_id,
                conversation_id: None,
                // Orchestrator-driven launch (inside `application`): no OS/runtime
                // facts to inject here; the real MCP declaration is written when the
                // agent is (re)launched through the app-tauri composition root.
                mcp_runtime: None,
                allow_structured_alongside_pty: false,
            })
            .await?;

        Ok(OrchestratorOutcome {
            detail: match visibility {
                OrchestratorVisibility::Background => {
                    format!("launched agent {name} in background")
                }
                OrchestratorVisibility::Visible { node_id } => {
                    format!("launched agent {name} in cell {node_id}")
                }
            },
            reply: None,
        })
    }

    /// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous**.
    ///
    /// The MCP tool and the file watcher both enter here, but they are only entrypoints
    /// into the same application orchestration path:
    ///
    /// 1. Resolve the target by name and acquire its **per-agent turn lock** so two
    ///    `ask`s for the same target serialise FIFO (1 agent = 1 employee).
    /// 2. Resolve the requester/target conversation and reject wait-for cycles before
    ///    enqueueing anything.
    /// 3. Ensure the target has a structured/headless [`domain::ports::AgentSession`],
    ///    launching it through the profile adapter if it is cold.
    /// 4. Drive the turn directly through [`domain::ports::AgentSession::send`] and
    ///    drain until the structured `Final` is captured. The target does **not** see
    ///    a ticket and does **not** call `idea_reply`.
    /// 5. Return the captured final answer as [`OrchestratorOutcome::reply`] and publish
    ///    [`DomainEvent::AgentReplied`].
    ///
    /// # Errors
    /// - [`AppError::NotFound`] if the target agent is unknown;
    /// - [`AppError::Invalid`] if structured/headless orchestration is not wired, or if
    ///   the target profile cannot be driven as a structured session;
    /// - [`AppError::Process`] on launch/session failure, or on the await timeout
    ///   (turn timeout *or* queue-wait timeout — same typed error).
    async fn ask_agent(
        &self,
        project: &Project,
        target: String,
        task: String,
        requester: Option<AgentId>,
    ) -> Result<OrchestratorOutcome, AppError> {
        let agent = self
            .find_agent_by_name(project, &target)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
        let agent_id = agent.id;
        // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
        // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
        // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
        if let Some(from) = requester {
            let cycles = {
                let g = self
                    .wait_for
                    .lock()
                    .unwrap_or_else(std::sync::PoisonError::into_inner);
                g.would_cycle(from, agent_id)
            };
            if cycles {
                crate::diag!(
                    "[rendezvous] cycle refused: requester={from} -> target={target} \
                     (agent {agent_id}) (deadlock évité, aucun enqueue)",
                );
                return Err(AppError::Invalid(format!(
                    "délégation ré-entrante refusée : demander à l'agent '{target}' créerait \
                     un cycle d'attente inter-agents (deadlock évité)"
                )));
            }
        }

        // Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon
        // User↔B. La session vivante est désormais keyée par conversation (lève
        // `session-registry-agent-ambiguity`).
        let conversation_id = self.resolve_conversation(project, requester, agent_id);

        // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
        // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
        let lock = self.ask_lock_for(&agent_id);
        // Diagnostics : tracer l'attente du verrou de tour. Un « turn-lock waiting » sans
        // « turn-lock acquired » qui suit signe un tour précédent jamais relâché (la cible
        // reste Busy) — le verrou n'est libéré qu'à la fin du tour courant.
        crate::diag!(
            "[rendezvous] turn-lock waiting: requester={} -> target={target} (agent {agent_id}) \
             wait_cap_ms={}",
            requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
            ASK_QUEUE_WAIT_CAP.as_millis(),
        );
        let lock_wait = Instant::now();
        let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
            Ok(guard) => {
                crate::diag!(
                    "[rendezvous] turn-lock acquired: target={target} (agent {agent_id}) \
                     waited_ms={}",
                    lock_wait.elapsed().as_millis(),
                );
                guard
            }
            Err(_elapsed) => {
                crate::diag!(
                    "[rendezvous] turn-lock TIMEOUT: target={target} (agent {agent_id}) \
                     waited_ms={} (tour précédent jamais relâché ?)",
                    lock_wait.elapsed().as_millis(),
                );
                return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
            }
        };

        // Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
        let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));

        // ── Chemin **structuré/headless uniquement** ──────────────────────────────
        // La conversation inter-agent ne passe plus par le PTY ni par le rendez-vous
        // `idea_reply` MCP. La cible doit être pilotable via `AgentSession::send`; son
        // `Final` est la seule réponse normale du tour. Les profils legacy PTY/TUI sont
        // refusés au lieu de retomber sur l'ancien chemin MCP fragile.
        let structured = self.structured.as_ref().ok_or_else(|| {
            AppError::Invalid(
                "la conversation inter-agent headless n'est pas disponible : registre \
                 de sessions structurées non câblé"
                    .to_owned(),
            )
        })?;
        let session = self
            .ensure_structured_session(project, agent_id, &agent.profile_id, structured)
            .await?
            .ok_or_else(|| {
                AppError::Invalid(format!(
                    "la cible '{target}' ne peut pas recevoir de conversation inter-agent : \
                     son profil ne déclare pas d'adaptateur structured/headless"
                ))
            })?;
        self.bind_conversation_session(conversation_id, session.id());
        self.ask_structured(
            project,
            agent_id,
            &target,
            conversation_id,
            requester,
            task,
            session.as_ref(),
        )
        .await
    }

    /// Chemin `ask` **structuré** (readiness/heartbeat lot 1) : la cible a un
    /// `structured_adapter` et une session vivante ([`StructuredSessions`]). On pilote
    /// son tour **directement** sur le port [`domain::ports::AgentSession`] et on le
    /// draine via [`drain_with_readiness`] : le [`domain::ports::ReplyEvent::Final`]
    /// déterministe rend le contenu (réveille le `pending`) **et** marque l'agent `Idle`
    /// (`mark_idle`), sans dépendre d'un `idea_reply` explicite ni d'un PTY (que la cible
    /// n'a pas). C'est le fix de la cause racine du blocage `Busy`.
    ///
    /// On enregistre tout de même un ticket dans la FIFO (`enqueue`) pour la comptabilité
    /// busy et l'exclusion mutuelle, mais il n'est plus une source de réponse : le ticket
    /// est retiré quand le [`domain::ports::ReplyEvent::Final`] structured a été reçu.
    /// Le checkpoint Prompt/Response best-effort est conservé à l'identique du chemin PTY.
    #[allow(clippy::too_many_arguments)]
    async fn ask_structured(
        &self,
        project: &Project,
        agent_id: AgentId,
        target: &str,
        conversation_id: domain::conversation::ConversationId,
        requester: Option<AgentId>,
        task: String,
        session: &dyn domain::ports::AgentSession,
    ) -> Result<OrchestratorOutcome, AppError> {
        let (input, mailbox) = match (&self.input, &self.mailbox) {
            (Some(i), Some(m)) => (i, m),
            _ => {
                return Err(AppError::Invalid(
                    "la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
                 médiateur d'entrée non câblé"
                        .to_owned(),
                ))
            }
        };
        let agent_key = runtime_key(project, agent_id);

        // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
        let prompt_source = match requester {
            Some(from) => InputSource::agent(from),
            None => InputSource::Human,
        };
        self.record_turn_best_effort(
            &project.root,
            conversation_id,
            prompt_source,
            TurnRole::Prompt,
            task.clone(),
        )
        .await;

        // Ticket dans la FIFO : comptabilité busy + exclusion mutuelle. La réponse ne
        // viendra plus de cette mailbox, seulement du `Final` structured/headless.
        let requester_label = self.requester_label(project, requester).await;
        let ticket_id = TicketId::new_random();
        let ticket = match requester {
            Some(from) => Ticket::from_agent(
                ticket_id,
                from,
                conversation_id,
                requester_label,
                task.clone(),
            ),
            None => Ticket::from_human(ticket_id, conversation_id, requester_label, task.clone()),
        };
        // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT
        // 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_silent(agent_key, ticket);
        let rendezvous_link = BackgroundTaskRendezvousLink {
            rendezvous_id: RendezvousId::new_random(),
            ticket_id,
            requester_agent_id: requester,
            target_agent_id: agent_id,
            conversation_id,
        };
        let rendezvous_task = self
            .start_rendezvous_task(project, agent_id, rendezvous_link.clone())
            .await?;
        let _active_rendezvous =
            ActiveRendezvousGuard::new(self, agent_key, rendezvous_link.clone());
        // 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
        // le fix de la cause racine du blocage `Busy` à vie.
        let busy_guard =
            BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_key, ticket_id);

        // Rendezvous beacon (chemin structuré) : équivalent du « ask started » du chemin
        // PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final`
        // de sa session structured/headless.
        let started = Instant::now();
        crate::diag!(
            "[rendezvous] ask started (structured): requester={} -> target={target} \
             (agent {agent_id}) conversation={conversation_id} ticket={ticket_id} \
             turn_timeout_ms={}",
            requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
            turn_timeout.as_millis(),
        );

        // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`). Le drain lui-même
        // est **non borné** (`None`) : la borne de tour est désormais portée par la
        // **fenêtre d'inactivité réarmable** autour de l'attente (signe de vie = sonde de
        // transcript), pas par un timeout plat interne — un long tour unique qui progresse
        // n'est plus coupé à `turn_timeout`. Aucun `idea_reply` ne peut résoudre ce tour.
        let announcement_publisher = self.events.as_ref().map(|bus| AnnouncementPublisher {
            bus: Arc::clone(bus),
            project_id: project.id,
            requester: requester.map_or(ConversationParty::User, ConversationParty::agent),
            target: agent_id,
            ticket: ticket_id,
        });
        let drain = drain_with_readiness_and_announcements_outcome(
            session,
            &task,
            None,
            input.as_ref(),
            agent_key,
            announcement_publisher,
        );

        // L'attente du rendez-vous structured, rendue comme `Result<String, AppError>`
        // pour être enveloppée par le watchdog. Un flux clos sans `Final` correspond
        // au no-reply du chemin headless : la cible a rendu la main sans réponse
        // exploitable, on expose donc l'erreur métier retryable plutôt qu'un PROCESS.
        let wait = async {
            match drain.await {
                Ok(TurnOutcome::Completed(content)) => Ok(content),
                Ok(TurnOutcome::RateLimited { resets_at_ms }) => {
                    let conversation_id = session.conversation_id();
                    let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
                        (Some(conversation_id), resets_at_ms) => {
                            (Some(conversation_id), resets_at_ms)
                        }
                        (None, None) => (None, None),
                        // Reset connu mais conversation moteur absente : ne pas armer
                        // une reprise non reprenable ; surfacer le fallback humain.
                        (None, Some(_)) => (None, None),
                    };
                    if let (Some(service), Some(structured)) =
                        (&self.session_limits, &self.structured)
                    {
                        if let Some(node_id) =
                            structured.node_for_agent_in_project(project.id, &agent_id)
                        {
                            service.on_rate_limited(
                                project.id,
                                agent_id,
                                node_id,
                                conversation_id,
                                resets_at_ms,
                            );
                        }
                    }
                    Err(AppError::Process(
                        "le tour s'est clos en limite de session, sans contenu Final".to_owned(),
                    ))
                }
                Err(err) if structured_no_reply_error(&err) => {
                    Err(AppError::TargetReturnedNoReply(target.to_owned()))
                }
                Err(err) => Err(AppError::from(err)),
            }
        };

        // Borne par la fenêtre d'inactivité (réarmée sur signe de vie) sous plafond absolu.
        let initial_result = match self
            .run_ask_with_watchdog(wait, turn_timeout, &project.root, agent_id, target, started)
            .await
        {
            WatchdogOutcome::Resolved(Ok(content)) => {
                crate::diag!(
                    "[rendezvous] ask turn finalized (structured): target={target} \
                     (agent {agent_id}) ticket={ticket_id} after_ms={} reply_len={}",
                    started.elapsed().as_millis(),
                    content.len(),
                );
                content
            }
            // Erreur de tour (drain-error, no-reply, canal fermé) : le garde fait
            // `cancel_head` + `mark_idle` au Drop. On réconcilie la live-state à `Done`
            // pour qu'un abandon ne laisse pas un busy fantôme.
            WatchdogOutcome::Resolved(Err(err)) => {
                let task_error = if matches!(
                    err,
                    AppError::TargetReturnedNoReply(_) | AppError::Process(_)
                ) {
                    format!("NoReply: {err}")
                } else {
                    err.to_string()
                };
                if task_error.to_ascii_lowercase().contains("cancelled")
                    || task_error.to_ascii_lowercase().contains("annul")
                {
                    self.complete_rendezvous_task_cancelled(
                        project,
                        agent_id,
                        rendezvous_task,
                        task_error,
                    )
                    .await?;
                } else {
                    self.complete_rendezvous_task_failure(
                        project,
                        agent_id,
                        rendezvous_task,
                        task_error,
                    )
                    .await?;
                }
                self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                    .await;
                crate::diag!(
                    "[rendezvous] ask error (structured): target={target} (agent {agent_id}) \
                     ticket={ticket_id} after_ms={} err={err}",
                    started.elapsed().as_millis(),
                );
                return Err(err);
            }
            // Plafond absolu atteint alors que la cible progresse encore : verdict distinct.
            WatchdogOutcome::CeilingActive => {
                self.complete_rendezvous_task_failure(
                    project,
                    agent_id,
                    rendezvous_task,
                    format!("Timeout: rendezvous absolute ceiling reached for target {target}"),
                )
                .await?;
                self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                    .await;
                crate::diag!(
                    "[rendezvous] ask CEILING (structured, still active): target={target} \
                     (agent {agent_id}) ticket={ticket_id} after_ms={}",
                    started.elapsed().as_millis(),
                );
                return Err(AppError::TargetCeilingActive(target.to_owned()));
            }
            // Vrai silence (aucun progrès sur la fenêtre) : timeout typé, identique à
            // l'ancienne borne plate. Live-state réconciliée à `Done`.
            WatchdogOutcome::NoReply => {
                self.complete_rendezvous_task_failure(
                    project,
                    agent_id,
                    rendezvous_task,
                    format!("Timeout: rendezvous inactivity window expired for target {target}"),
                )
                .await?;
                self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                    .await;
                crate::diag!(
                    "[rendezvous] ask TIMEOUT (structured): target={target} (agent {agent_id}) \
                     ticket={ticket_id} after_ms={} (aucun progrès observé sur la fenêtre)",
                    started.elapsed().as_millis(),
                );
                return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
            }
        };

        let result = if self
            .active_rendezvous_has_background_tasks(agent_key, rendezvous_link.rendezvous_id)
        {
            self.mark_rendezvous_task_waiting(rendezvous_task).await?;
            crate::diag!(
                "[rendezvous] ask waiting on background: target={target} \
                 (agent {agent_id}) ticket={ticket_id} rendezvous={} first_final_len={}",
                rendezvous_link.rendezvous_id,
                initial_result.len(),
            );
            let remaining = self.ask_ceiling.saturating_sub(started.elapsed());
            match tokio::time::timeout(remaining, pending).await {
                Ok(Ok(domain::mailbox::TurnResolution::Replied(content))) => content,
                Ok(Ok(domain::mailbox::TurnResolution::ReturnedToPromptNoReply)) => {
                    let err = AppError::TargetReturnedNoReply(target.to_owned());
                    self.complete_rendezvous_task_failure(
                        project,
                        agent_id,
                        rendezvous_task,
                        format!("NoReply: {err}"),
                    )
                    .await?;
                    self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                        .await;
                    return Err(err);
                }
                Ok(Err(err)) => {
                    let err = AppError::Process(err.to_string());
                    self.complete_rendezvous_task_failure(
                        project,
                        agent_id,
                        rendezvous_task,
                        format!("NoReply: {err}"),
                    )
                    .await?;
                    self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                        .await;
                    return Err(err);
                }
                Err(_elapsed) => {
                    self.complete_rendezvous_task_failure(
                        project,
                        agent_id,
                        rendezvous_task,
                        format!(
                            "Timeout: composite rendezvous ceiling reached for target {target}"
                        ),
                    )
                    .await?;
                    self.mark_target_done_best_effort(&project.root, agent_id, ticket_id)
                        .await;
                    return Err(AppError::TargetCeilingActive(target.to_owned()));
                }
            }
        } else {
            initial_result
        };

        self.complete_rendezvous_task_success(project, agent_id, rendezvous_task, &result)
            .await?;

        // Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de
        // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII.
        mailbox.cancel_head(agent_key, ticket_id);
        input.mark_idle(agent_key);
        busy_guard.disarm();

        // Checkpoint Response (best-effort), AVANT de déplacer `result`.
        self.record_turn_best_effort(
            &project.root,
            conversation_id,
            InputSource::agent(agent_id),
            TurnRole::Response,
            result.clone(),
        )
        .await;
        // Auto-memory harvest (Lot E1), après l'append/handoff de la réponse.
        // 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))
    }

    /// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
    ///
    /// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
    /// thread, ensures the target is live in the PTY registry, binds its handle on the
    /// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the
    /// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a
    /// delegation serialise on the same agent («1 agent = 1 employee»).
    ///
    /// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the
    /// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply
    /// slot is registered and simply left to resolve/expire on its own). The busy
    /// event is emitted at the mediator's source (Idle→Busy on the starting enqueue).
    ///
    /// # Errors
    /// - [`AppError::Invalid`] if the input mediator is not wired;
    /// - [`AppError::NotFound`] if the target agent is unknown;
    /// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session.
    pub async fn submit_human_input(
        &self,
        project: &Project,
        agent_id: AgentId,
        text: String,
    ) -> Result<OrchestratorOutcome, AppError> {
        let input = self.input.as_ref().ok_or_else(|| {
            AppError::Invalid(
                "l'entrée médiée (submit_agent_input) n'est pas disponible : \
                 médiateur d'entrée non câblé"
                    .to_owned(),
            )
        })?;

        // Display label for error/launch messages; the agent must exist in the
        // manifest. A human submit to an unknown id is a NotFound, never a panic.
        let target = self
            .find_name_by_agent_id(project, agent_id)
            .await
            .ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?;
        let target = target.as_str();

        // User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask.
        let agent_key = runtime_key(project, agent_id);
        let conversation_id = self.resolve_conversation(project, None, agent_id);

        // Ensure the target is live for this thread and bind its input handle on the
        // mediator (delivery path). Same call the ask path uses.
        let (handle, cold_launch) = self
            .ensure_live_pty(project, agent_id, conversation_id, target)
            .await?;
        let (submit, has_mcp) = self.submit_and_mcp_for_agent(project, agent_id).await;
        // Gate cold-launch : un agent froid n'est pas encore prêt. On diffère le 1er tour
        // s'il existe un signal pour le libérer — la connexion du pont MCP de l'agent
        // (`InputMediator::release_cold_start`, déclenchée par l'McpServer). Seul signal
        // de readiness restant (watcher prompt-ready PTY supprimé). Sans pont MCP ⇒ pas
        // de gate (livraison immédiate, sinon blocage indéfini).
        let gate_cold_start = cold_launch && has_mcp;
        if gate_cold_start {
            input.mark_starting(agent_key);
        }
        input.bind_handle_with_submit(agent_key, handle, submit);

        // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
        // forget: we drop the PendingReply (the human reads the terminal). The
        // mediator emits AgentBusyChanged at the source on a starting turn.
        let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text);
        let _pending = input.enqueue(agent_key, ticket);

        Ok(OrchestratorOutcome {
            detail: format!("submitted human input to agent {target}"),
            reply: None,
        })
    }

    /// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop).
    ///
    /// Resolves the target by name and calls [`InputMediator::preempt`], which signals
    /// the running turn to stop (best-effort interrupt byte to the agent's bound PTY
    /// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller
    /// is never silently answered. Idempotent: interrupting an idle agent is a no-op.
    ///
    /// # Errors
    /// - [`AppError::Invalid`] if the input mediator is not wired;
    /// - [`AppError::NotFound`] if the target agent is unknown.
    pub async fn interrupt_agent(
        &self,
        project: &Project,
        agent_id: AgentId,
    ) -> Result<OrchestratorOutcome, AppError> {
        let input = self.input.as_ref().ok_or_else(|| {
            AppError::Invalid(
                "l'interruption (interrupt_agent) n'est pas disponible : \
                 médiateur d'entrée non câblé"
                    .to_owned(),
            )
        })?;

        // Confirm the agent exists (typed NotFound rather than a silent no-op on a
        // bogus id). The manifest lookup also keeps the contract symmetric with submit.
        if self
            .find_name_by_agent_id(project, agent_id)
            .await
            .is_none()
        {
            return Err(AppError::NotFound(format!("agent {agent_id}")));
        }

        input.preempt(runtime_key(project, agent_id));

        Ok(OrchestratorOutcome {
            detail: format!("interrupted agent {agent_id}"),
            reply: None,
        })
    }

    /// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
    ///
    /// The frontend write-portal calls this (via the `delegation_delivered` Tauri
    /// command) once it has **physically written** a delegation `ticket` into the agent's
    /// native PTY, to distinguish "queued because the human line was busy" from "written"
    /// in logs/observability. It **does not** change correlation: the requester's `ask`
    /// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
    /// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
    /// mediator/registry never breaks the rendezvous.
    pub fn note_delegation_delivered(
        &self,
        project: &Project,
        agent_id: AgentId,
        ticket: TicketId,
    ) {
        // Observability beacon only: never mutates the mailbox/busy state nor resolves
        // the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
        // a pure, infallible hook so a missing mediator/registry never breaks the
        // rendezvous.
        let _ = project;
        crate::diag!(
            "[delivery] front ack received: agent={agent_id} ticket={ticket} \
             (write-portal reports text+submit writes completed)"
        );
    }

    /// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se
    /// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le
    /// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé.
    pub fn release_agent_cold_start(&self, project: &Project, agent: domain::AgentId) {
        if let Some(input) = &self.input {
            input.release_cold_start(runtime_key(project, agent));
        }
    }

    /// Résout l'`AgentId` d'un agent par son **nom d'affichage** (insensible à la casse)
    /// dans le manifeste du projet, pour la composition root (qui ne connaît la cible d'un
    /// `idea_ask_agent` que par son nom). Expose la résolution privée `find_agent_id_by_name`
    /// afin que le filet serveur MCP puisse dériver le run-dir transcript de la cible (sonde
    /// d'activité du rendez-vous). `None` si aucun agent ne porte ce nom.
    ///
    /// # Errors
    /// Propage l'[`AppError`] du listing des agents (lecture du manifeste).
    pub async fn resolve_agent_id_by_name(
        &self,
        project: &Project,
        name: &str,
    ) -> Result<Option<domain::AgentId>, AppError> {
        self.find_agent_id_by_name(project, name).await
    }

    /// Déclare si une **cellule terminal frontend** est montée pour `agent` (write-portal
    /// actif). Pont entre le write-portal (adapter UI) et le médiateur : un agent avec
    /// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un
    /// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire
    /// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur.
    pub fn set_agent_front_attached(
        &self,
        project: &Project,
        agent: domain::AgentId,
        attached: bool,
    ) {
        crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}");
        if let Some(input) = &self.input {
            input.set_front_attached(runtime_key(project, agent), attached);
        } else {
            crate::diag!(
                "[delivery] front attachment ignored because input mediator is not wired: \
                 agent={agent} attached={attached}"
            );
        }
    }

    /// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
    /// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
    /// stable per-agent id derived from the target (legacy routing — never panics).
    fn resolve_conversation(
        &self,
        project: &Project,
        requester: Option<AgentId>,
        target: AgentId,
    ) -> domain::conversation::ConversationId {
        let left = match requester {
            Some(from) => ConversationParty::agent(from),
            None => ConversationParty::User,
        };
        let right = ConversationParty::agent(target);
        match &self.conversations {
            Some(reg) => reg.resolve(project.id, left, right).id,
            // Repli pur déterministe partagé avec `LaunchAgent` (ARCHITECTURE §19.7,
            // lot P8a) : la même paire dérive la même clé de conversation des deux
            // côtés (sauvegarde du handoff ici, dérivation côté cellule là-bas).
            None => domain::conversation::ConversationId::for_project_pair(project.id, left, right),
        }
    }

    /// `agent.reply` / `idea_reply`: the target agent renders the result of the task
    /// it is currently processing (Option 1, lot B-4).
    ///
    /// Positional correlation: `from` is the **emitting** agent (its identity comes
    /// from the MCP handshake, not from a model-managed id), so the result resolves
    /// the ticket at the **head** of *that agent's* mailbox queue — the task it is
    /// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs
    /// to the asking side's `ask_agent`).
    ///
    /// # Errors
    /// - [`AppError::Invalid`] if the mailbox is not wired;
    /// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no
    ///   matching ask) — typed, never a panic.
    fn reply(
        &self,
        project: &Project,
        from: AgentId,
        ticket: Option<TicketId>,
        result: String,
    ) -> Result<OrchestratorOutcome, AppError> {
        // Diagnostics : un `idea_reply` est entré, AVANT toute corrélation. Longueur
        // seulement (jamais le corps). Si ce beacon apparaît mais que `correlated`/
        // `UNMATCHED` ne suit pas, la file n'est pas câblée.
        crate::diag!(
            "[rendezvous] idea_reply received: from agent {from} ticket={} result_len={}",
            ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
            result.len(),
        );
        let mailbox = self.mailbox.as_ref().ok_or_else(|| {
            crate::diag!(
                "[rendezvous] idea_reply DROPPED: from agent {from} (file inter-agents non câblée)",
            );
            AppError::Invalid(
                "idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(),
            )
        })?;
        let from_key = runtime_key(project, from);
        // Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
        // sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
        let correlation = match ticket {
            Some(ticket_id) => mailbox.resolve_ticket(from_key, ticket_id, result),
            None => mailbox.resolve(from_key, result),
        };
        // Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a
        // corrélé à un ask en vol — un échec ici (« no matching ask ») signe une
        // délégation déjà expirée/abandonnée ou un ticket erroné côté cible.
        match &correlation {
            Ok(()) => crate::diag!(
                "[rendezvous] idea_reply correlated: from agent {from} ticket={}",
                ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
            ),
            Err(e) => crate::diag!(
                "[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}",
                ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()),
            ),
        }
        correlation.map_err(|e| AppError::Invalid(e.to_string()))?;
        // Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
        // emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
        // advances to the next queued ticket. This is the deterministic OR signal that
        // pairs with prompt-ready detection; whichever fires first frees the turn. No-op
        // (and no spurious event) when the mediator is absent or `from` was already idle.
        if let Some(input) = self.input.as_ref() {
            input.mark_idle(from_key);
        }
        Ok(OrchestratorOutcome {
            detail: format!("reply from agent {from} delivered"),
            reply: None,
        })
    }

    /// Ensures the target agent has a **live PTY session**, returning its handle.
    ///
    /// Reuses the running terminal when present; otherwise launches the agent in the
    /// background (a normal PTH launch — a live PTY is the delegation channel). After
    /// a launch the handle is resolved from the registry; a missing handle is a
    /// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
    /// IdeA cannot drive as a terminal).
    ///
    /// Le booléen renvoyé indique un **lancement à froid** (`true` quand l'agent n'avait
    /// pas de session vivante et vient d'être démarré) : l'appelant l'utilise pour
    /// *gater* le premier tour sur le prompt-ready (cf. [`InputMediator::mark_starting`]),
    /// car un agent froid n'est pas encore à son prompt. `false` ⇒ session réutilisée
    /// (agent déjà chaud) ⇒ livraison immédiate, comportement inchangé.
    async fn ensure_live_pty(
        &self,
        project: &Project,
        agent_id: AgentId,
        conversation_id: domain::conversation::ConversationId,
        target: &str,
    ) -> Result<(PtyHandle, bool), AppError> {
        // «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
        // session du **fil**, puis on retombe sur la session de l'agent (compat : un
        // agent mono-fil dont la session n'a pas encore été liée à sa conversation).
        let existing = self.sessions.session_for(conversation_id).or_else(|| {
            self.sessions
                .session_for_agent_in_project(project.id, &agent_id)
        });
        if let Some(session_id) = existing {
            if let Some(handle) = self.sessions.handle(&session_id) {
                // (Re)lier le fil à cette session vivante (idempotent). Réutilisation
                // d'une session déjà chaude ⇒ pas de gate de démarrage à froid.
                self.bind_conversation_session(conversation_id, session_id);
                return Ok((handle, false));
            }
        }

        // Dead target: launch it in the background (PTY). On injecte ici la déclaration
        // MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
        // l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc
        // à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`)
        // ⇒ déclaration minimale comme avant (dégradation gracieuse).
        self.launch_agent
            .execute(LaunchAgentInput {
                project: project.clone(),
                agent_id,
                rows: DEFAULT_ROWS,
                cols: DEFAULT_COLS,
                node_id: None,
                conversation_id: None,
                mcp_runtime: self
                    .mcp_runtime_provider
                    .as_ref()
                    .and_then(|p| p.runtime_for(project, agent_id)),
                allow_structured_alongside_pty: false,
            })
            .await?;

        let session_id = self
            .sessions
            .session_for_agent_in_project(project.id, &agent_id)
            .ok_or_else(|| {
                AppError::Process(format!(
                    "agent {target} n'a pas de session terminal vivante après lancement"
                ))
            })?;
        // Lier la session fraîchement lancée à CE fil (registre terminal + registre de
        // conversations) ⇒ un prochain ask sur le même fil la réutilise.
        self.bind_conversation_session(conversation_id, session_id);
        let handle = self.sessions.handle(&session_id).ok_or_else(|| {
            AppError::Process(format!(
                "handle PTY de l'agent {target} introuvable après lancement"
            ))
        })?;
        // Lancement à froid : `true` ⇒ l'appelant gatera le premier tour sur le prompt.
        Ok((handle, true))
    }

    /// Lot 1b — garantit une **session structurée vivante** pour la cible d'un `ask`.
    ///
    /// Retourne :
    /// - `Ok(Some(session))` si la cible a déjà une session vivante, **ou** si son
    ///   profil porte un `structured_adapter` et qu'on vient de la (re)lancer ;
    /// - `Ok(None)` si la cible n'est **pas** structurée (profil sans `structured_adapter`,
    ///   ou profil introuvable) ⇒ `AskAgent` refuse la conversation inter-agent.
    ///
    /// L'auto-lancement réutilise le **même** [`LaunchAgent`] que les lancements UI :
    /// pour un profil structuré, le launcher route §17.4 vers `launch_structured`,
    /// démarre la session via la fabrique et l'insère dans le registre
    /// [`StructuredSessions`] **partagé**. Le runtime MCP peut encore être matérialisé
    /// pour les outils non conversationnels, mais il ne participe plus à la résolution
    /// de la réponse inter-agent.
    async fn ensure_structured_session(
        &self,
        project: &Project,
        agent_id: AgentId,
        profile_id: &ProfileId,
        structured: &Arc<StructuredSessions>,
    ) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
        // Cible déjà chaude : route directe (aucun lancement).
        if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) {
            return Ok(Some(session));
        }

        // Cible froide : ne (re)lancer que si le profil sait être piloté en mode
        // structuré. Sinon (agent PTY/TUI legacy, ou profil introuvable) ⇒ refus par
        // l'appelant.
        let is_structured = self
            .profiles
            .list()
            .await?
            .into_iter()
            .find(|p| &p.id == profile_id)
            .is_some_and(|p| p.is_selectable());
        if !is_structured {
            return Ok(None);
        }

        // Démarrer la session via le launcher (route §17.4 → `launch_structured`,
        // insère dans CE registre) sans fermer une éventuelle session PTY visible du
        // même agent. Le flag interne garde les deux canaux séparés : PTY pour la
        // cellule humaine, `AgentSession::send` pour `idea_ask_agent`.
        self.launch_agent
            .execute(LaunchAgentInput {
                project: project.clone(),
                agent_id,
                rows: DEFAULT_ROWS,
                cols: DEFAULT_COLS,
                node_id: None,
                conversation_id: None,
                mcp_runtime: self
                    .mcp_runtime_provider
                    .as_ref()
                    .and_then(|p| p.runtime_for(project, agent_id)),
                allow_structured_alongside_pty: true,
            })
            .await?;

        // Le launcher a inséré la session dans le registre partagé : la relire.
        structured
            .session_for_agent_in_project(project.id, &agent_id)
            .map(Some)
            .ok_or_else(|| {
                AppError::Process(format!(
                    "agent {agent_id} : aucune session structurée vivante après lancement"
                ))
            })
    }

    /// Binds `conversation` to `session` in both the terminal registry (fast
    /// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
    /// latter is wired. Idempotent.
    fn bind_conversation_session(
        &self,
        conversation: domain::conversation::ConversationId,
        session: domain::SessionId,
    ) {
        self.sessions.bind_conversation(conversation, session);
        if let Some(reg) = &self.conversations {
            reg.bind_session(conversation, SessionRef::new(session));
        }
    }

    /// Resolves a human-friendly label for the **requesting** agent to prefix the
    /// delegated task with. Best-effort: there is no requester id threaded into
    /// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable
    /// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can
    /// surface the real name without touching the call sites.
    async fn requester_label(&self, project: &Project, requester: Option<AgentId>) -> String {
        match requester {
            None => "un autre agent".to_owned(),
            Some(from) => self
                .find_name_by_agent_id(project, from)
                .await
                .unwrap_or_else(|| "un autre agent".to_owned()),
        }
    }

    /// Best-effort display name for an agent id (manifest lookup). `None` when the id
    /// is unknown — the caller falls back to a generic label.
    async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option<String> {
        self.list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await
            .ok()?
            .agents
            .into_iter()
            .find(|a| a.id == id)
            .map(|a| a.name)
    }

    /// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
    /// (best-effort: a missing event bus never fails the rendezvous).
    fn reply_outcome(
        &self,
        agent_id: domain::AgentId,
        target: &str,
        content: String,
    ) -> OrchestratorOutcome {
        if let Some(events) = &self.events {
            events.publish(DomainEvent::AgentReplied {
                agent_id,
                reply_len: content.len(),
            });
        }
        OrchestratorOutcome {
            detail: format!("agent {target} replied ({} bytes)", content.len()),
            reply: Some(content),
        }
    }

    /// `list_agents`: discovery — return the project's agents exactly as the UI
    /// reads them from the manifest, via the **same** [`ListAgents`] use case.
    ///
    /// The list is serialised as a JSON array into [`OrchestratorOutcome::reply`]
    /// (the existing inline-payload channel, also used by `ask`), with a one-line
    /// count in [`OrchestratorOutcome::detail`]. Each element carries the agent's
    /// `id`, `name`, `contextPath`, `profileId`, `origin`, `synchronized`,
    /// `skills` and the additive resolved `capabilities` list.
    ///
    /// # Errors
    /// Propagates [`AppError`] from the use case (manifest load / invariant) or a
    /// serialisation failure ([`AppError::Invalid`]).
    async fn list_agents(&self, project: &Project) -> Result<OrchestratorOutcome, AppError> {
        let listed = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await?;

        let entries = listed.discovery_entries();
        let reply = serde_json::to_string(&entries)
            .map_err(|e| AppError::Invalid(format!("failed to serialise agent list: {e}")))?;

        Ok(OrchestratorOutcome {
            detail: format!("listed {} agent(s)", entries.len()),
            reply: Some(reply),
        })
    }

    /// `stop_agent`: translate the agent name → its live session → `CloseTerminal`.
    async fn stop_agent(
        &self,
        project: &Project,
        name: String,
    ) -> Result<OrchestratorOutcome, AppError> {
        let agent_id = self
            .find_agent_id_by_name(project, &name)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;

        let session_id = self
            .sessions
            .session_for_agent_in_project(project.id, &agent_id)
            .ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?;

        self.close_terminal
            .execute(CloseTerminalInput { session_id })
            .await?;

        Ok(OrchestratorOutcome {
            detail: format!("stopped agent {name}"),
            reply: None,
        })
    }

    /// `update_agent_context`: overwrite the agent's `.md` body.
    async fn update_agent_context(
        &self,
        project: &Project,
        name: String,
        context: String,
    ) -> Result<OrchestratorOutcome, AppError> {
        let agent_id = self
            .find_agent_id_by_name(project, &name)
            .await?
            .ok_or_else(|| AppError::NotFound(format!("agent {name}")))?;

        self.update_context
            .execute(UpdateAgentContextInput {
                project: project.clone(),
                agent_id,
                content: context,
            })
            .await?;

        Ok(OrchestratorOutcome {
            detail: format!("updated context for agent {name}"),
            reply: None,
        })
    }

    /// `create_skill`: create a reusable skill in the requested scope — the same
    /// path the UI's "New skill" action takes. For [`SkillScope::Project`] the
    /// skill lands under `<root>/.ideai/skills/`; for [`SkillScope::Global`] the
    /// project root is ignored by the store.
    async fn create_skill(
        &self,
        project: &Project,
        name: String,
        content: String,
        scope: domain::SkillScope,
    ) -> Result<OrchestratorOutcome, AppError> {
        let created = self
            .create_skill
            .execute(CreateSkillInput {
                name: name.clone(),
                // `idea_create_skill` carries no description argument; the affordance
                // falls back to the body's first line (see `effective_description`).
                description: None,
                content,
                scope,
                kind: domain::SkillKind::Workflow,
                project_root: project.root.clone(),
            })
            .await?;

        Ok(OrchestratorOutcome {
            detail: format!("created skill {} ({:?})", created.skill.name, scope),
            reply: None,
        })
    }

    /// Finds an agent id by display name (case-insensitive) in the project manifest.
    async fn find_agent_id_by_name(
        &self,
        project: &Project,
        name: &str,
    ) -> Result<Option<domain::AgentId>, AppError> {
        let listed = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await?;
        Ok(listed
            .agents
            .into_iter()
            .find(|a| a.name.eq_ignore_ascii_case(name))
            .map(|a| a.id))
    }

    /// Finds the full [`domain::Agent`] by display name (case-insensitive) in the
    /// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve
    /// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2.
    async fn find_agent_by_name(
        &self,
        project: &Project,
        name: &str,
    ) -> Result<Option<domain::Agent>, AppError> {
        let listed = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await?;
        Ok(listed
            .agents
            .into_iter()
            .find(|a| a.name.eq_ignore_ascii_case(name)))
    }

    /// Resolves the target agent profile's **submit config**
    /// (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) **and** whether it
    /// declares an **MCP bridge**, in a single profile lookup. The submit config is
    /// carried into `bind_handle_with_submit` (echoed on the next `DelegationReady` so
    /// the frontend write-portal knows how to submit); the MCP flag drives the
    /// cold-launch gate (its `initialize` connection releases a deferred first turn).
    /// Returns `(default, false)` when the agent or its profile is absent — the safe
    /// fallback (no submit ⇒ the front applies its own `"\r"`/~60 ms default; no gate).
    async fn submit_and_mcp_for_agent(
        &self,
        project: &Project,
        agent_id: AgentId,
    ) -> (SubmitConfig, bool) {
        let Some(agent) = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await
            .ok()
            .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
        else {
            return (SubmitConfig::default(), false);
        };
        let Some(profile) = self
            .profiles
            .list()
            .await
            .ok()
            .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
        else {
            return (SubmitConfig::default(), false);
        };
        let submit = submit_config_for_profile(&profile);
        // 2e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
        // (`initialize`) sert de signal de readiness de démarrage pour libérer un 1er
        // tour différé (`release_cold_start`) — c'est ce qui arme le gate cold-launch.
        (submit, profile.mcp.is_some())
    }

    /// Résout la [`domain::profile::LivenessStrategy`] du profil de la cible (lot 2) :
    /// le seuil de stagnation (`stall_after_ms`) et le timeout de tour
    /// (`turn_timeout_ms`). `None`/absent ⇒ `(None, None)` : pas de détection de stall et
    /// repli sur les bornes par défaut codées en dur (zéro régression pour un profil sans
    /// bloc `liveness`).
    async fn liveness_for_agent(
        &self,
        project: &Project,
        agent_id: AgentId,
    ) -> (Option<u32>, Option<u32>) {
        let Some(agent) = self
            .list_agents
            .execute(ListAgentsInput {
                project: project.clone(),
            })
            .await
            .ok()
            .and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
        else {
            return (None, None);
        };
        let Some(profile) = self
            .profiles
            .list()
            .await
            .ok()
            .and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
        else {
            return (None, None);
        };
        match profile.liveness {
            Some(l) => (l.stall_after_ms, l.turn_timeout_ms),
            None => (None, None),
        }
    }

    /// Borne de tour effective pour un `ask` : le `turn_timeout_ms` du profil de la cible
    /// **quand il existe**, sinon le défaut historique [`ASK_AGENT_TIMEOUT`] (lot 2,
    /// timeouts pilotés par profil). Arme aussi le seuil de stagnation sur le médiateur
    /// avant l'enqueue (battement/`sweep_stalled`).
    async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration {
        let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
        if let Some(input) = &self.input {
            input.set_stall_threshold(runtime_key(project, agent_id), stall_after_ms);
        }
        resolve_turn_timeout(turn_timeout_ms)
    }

    /// Borne un `wait` (l'attente synchrone du rendez-vous délégué) par la **fenêtre
    /// d'inactivité réarmable** (cf. [`rendezvous`](crate::orchestrator::rendezvous)),
    /// au lieu d'un timeout plat. La fenêtre vaut `window` (profil sinon défaut, la
    /// **même** valeur que l'ancien timeout plat) ; à chaque expiration de fenêtre, la
    /// sonde de vivacité de la cible est consultée : progrès ⇒ réarmement jusqu'au
    /// plafond absolu [`Self::ask_ceiling`]. Verdict :
    /// - [`WatchdogOutcome::Resolved`] : le `wait` a résolu (succès/erreur de tour) ⇒ rendu tel quel ;
    /// - [`WatchdogOutcome::NoReply`] : vrai silence (aucun progrès sur une fenêtre) **ou**
    ///   fallback fenêtre plate (pas de sonde câblée) — sémantique **identique** à l'ancien
    ///   timeout de tour (l'appelant mappe vers le timeout typé) ;
    /// - [`WatchdogOutcome::CeilingActive`] : progrès observé mais plafond atteint ⇒ verdict
    ///   distinct (l'appelant mappe vers [`AppError::TargetCeilingActive`]).
    async fn run_ask_with_watchdog<R>(
        &self,
        wait: impl std::future::Future<Output = R>,
        window: Duration,
        project_root: &domain::project::ProjectPath,
        agent_id: AgentId,
        target: &str,
        started: Instant,
    ) -> WatchdogOutcome<R> {
        let has_probe = self.ask_liveness_probe.is_some();
        let probe = self.ask_liveness_probe.clone();
        let root = project_root.clone();
        let probe_fn = move || {
            let probe = probe.clone();
            let root = root.clone();
            async move {
                match &probe {
                    Some(p) => p(root, agent_id).await,
                    None => None,
                }
            }
        };
        let ext_target = target.to_owned();
        let exp_target = target.to_owned();
        let ceil_target = target.to_owned();
        let window_ms = window.as_millis();
        let ceiling_ms = self.ask_ceiling.as_millis();
        run_inactivity_watchdog(
            wait,
            window,
            self.ask_ceiling,
            started,
            has_probe,
            probe_fn,
            move |elapsed| {
                crate::diag!(
                    "[rendezvous] ask window extended (signe de vie): target={ext_target} \
                     (agent {agent_id}) window_ms={window_ms} elapsed_ms={}",
                    elapsed.as_millis(),
                );
            },
            move |elapsed| {
                crate::diag!(
                    "[rendezvous] ask EXPIRED (no progress): target={exp_target} \
                     (agent {agent_id}) window_ms={window_ms} elapsed_ms={}",
                    elapsed.as_millis(),
                );
            },
            move |elapsed| {
                crate::diag!(
                    "[rendezvous] ask CEILING active: target={ceil_target} (agent {agent_id}) \
                     ceiling_ms={ceiling_ms} elapsed_ms={}",
                    elapsed.as_millis(),
                );
            },
        )
        .await
    }

    /// Resolves a human-friendly profile reference (slug like `claude-code`,
    /// command like `claude`, or display name like `Claude Code`) to a configured
    /// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
    /// scanning the configured profiles' command and name.
    ///
    /// # Errors
    /// [`AppError::NotFound`] when no configured profile matches.
    async fn resolve_profile(&self, reference: &str) -> Result<ProfileId, AppError> {
        let needle = normalise(reference);
        let profiles = self.profiles.list().await?;
        profiles
            .into_iter()
            .find(|p| {
                normalise(&p.command) == needle
                    || normalise(&p.name) == needle
                    || p.id.to_string() == reference
            })
            .map(|p| p.id)
            .ok_or_else(|| AppError::NotFound(format!("profile matching '{reference}'")))
    }
}

/// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and
/// removes it on drop (reply, timeout, or any early return) — the same discipline as
/// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on
/// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the
/// service outlives the guard.
struct WaitEdgeGuard<'a> {
    graph: &'a StdMutex<WaitForGraph>,
    active_waits: &'a StdMutex<Vec<(AgentId, AgentId)>>,
    from: AgentId,
    to: AgentId,
}

impl<'a> WaitEdgeGuard<'a> {
    fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self {
        {
            let mut g = service
                .wait_for
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            g.add_edge(from, to);
        }
        {
            let mut waits = service
                .active_waits
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            if !waits.contains(&(from, to)) {
                waits.push((from, to));
            }
        }
        Self {
            graph: &service.wait_for,
            active_waits: &service.active_waits,
            from,
            to,
        }
    }
}

impl Drop for WaitEdgeGuard<'_> {
    fn drop(&mut self) {
        let mut g = self
            .graph
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        g.remove_edge(self.from, self.to);
        let mut waits = self
            .active_waits
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        waits.retain(|edge| *edge != (self.from, self.to));
    }
}

struct ActiveRendezvousGuard<'a> {
    active: &'a StdMutex<HashMap<RuntimeAgentKey, ActiveRendezvous>>,
    agent: RuntimeAgentKey,
    rendezvous_id: RendezvousId,
}

impl<'a> ActiveRendezvousGuard<'a> {
    fn new(
        service: &'a OrchestratorService,
        agent: RuntimeAgentKey,
        link: BackgroundTaskRendezvousLink,
    ) -> Self {
        let rendezvous_id = link.rendezvous_id;
        service
            .active_rendezvous
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .insert(
                agent,
                ActiveRendezvous {
                    link,
                    background_tasks: Vec::new(),
                },
            );
        Self {
            active: &service.active_rendezvous,
            agent,
            rendezvous_id,
        }
    }
}

impl Drop for ActiveRendezvousGuard<'_> {
    fn drop(&mut self) {
        let mut active = self
            .active
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        if active
            .get(&self.agent)
            .is_some_and(|entry| entry.link.rendezvous_id == self.rendezvous_id)
        {
            active.remove(&self.agent);
        }
    }
}

/// Normalises a profile reference for tolerant matching: lowercased, with spaces,
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
/// → comparable forms; `claude` ⊂ ... handled by the command match above).
fn normalise(s: &str) -> String {
    s.chars()
        .filter(|c| c.is_ascii_alphanumeric())
        .map(|c| c.to_ascii_lowercase())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
    use domain::ProfileId;

    // (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut.
    #[test]
    fn profile_turn_timeout_overrides_default() {
        // Seuil de profil défini ⇒ il prime sur ASK_AGENT_TIMEOUT.
        assert_eq!(
            resolve_turn_timeout(Some(120_000)),
            Duration::from_millis(120_000)
        );
        // Absent (profil sans liveness / sans turn_timeout_ms) ⇒ repli sur le défaut.
        assert_eq!(resolve_turn_timeout(None), ASK_AGENT_TIMEOUT);
    }

    fn profile(id: u128, name: &str, command: &str) -> AgentProfile {
        AgentProfile::new(
            ProfileId::from_uuid(uuid::Uuid::from_u128(id)),
            name,
            command,
            Vec::new(),
            ContextInjection::convention_file("CLAUDE.md").unwrap(),
            None,
            "{agentRunDir}",
            None,
        )
        .unwrap()
    }

    #[test]
    fn normalise_makes_slug_command_and_name_comparable() {
        assert_eq!(normalise("Claude Code"), "claudecode");
        assert_eq!(normalise("claude-code"), "claudecode");
        assert_eq!(normalise("claude_code"), "claudecode");
    }

    #[test]
    fn resolve_matches_by_command_name_or_id() {
        // We exercise the pure matching predicate the same way `resolve_profile`
        // does, without standing up the whole service/ports.
        let p = profile(1, "Claude Code", "claude");
        let by_command = normalise("claude") == normalise(&p.command);
        let by_name = normalise("claude-code") == normalise(&p.name);
        assert!(by_command);
        assert!(by_name);
        assert_eq!(p.id.to_string(), p.id.to_string());
    }

    #[test]
    fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() {
        let p = profile(2, "OpenAI Codex CLI", "codex")
            .with_structured_adapter(StructuredAdapter::Codex);

        let submit = submit_config_for_profile(&p);

        assert_eq!(submit.sequence, None);
        assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
    }

    #[test]
    fn rendezvous_context_is_extracted_from_headless_background_task_kind() {
        let requester = aid(1);
        let target = aid(2);
        let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3));
        let task = BackgroundTask::new(
            TaskId::from_uuid(uuid::Uuid::from_u128(4)),
            domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)),
            target,
            BackgroundTaskKind::HeadlessRendezvous {
                requester_agent_id: Some(requester),
                target_agent_id: target,
                ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)),
                conversation_id,
            },
            BackgroundTaskWakePolicy::RecordOnly,
            100,
            None,
        )
        .unwrap();

        let context = rendezvous_context_for_task(&task).expect("rendezvous context");

        assert_eq!(context.requester_agent_id, Some(requester));
        assert_eq!(context.target_agent_id, target);
        assert_eq!(context.conversation_id, conversation_id);
    }

    #[test]
    fn rendezvous_context_is_absent_for_command_background_task() {
        let task = BackgroundTask::new(
            TaskId::from_uuid(uuid::Uuid::from_u128(7)),
            domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)),
            aid(9),
            BackgroundTaskKind::Command {
                label: "cargo test".to_owned(),
            },
            BackgroundTaskWakePolicy::RecordOnly,
            100,
            None,
        )
        .unwrap();

        assert_eq!(rendezvous_context_for_task(&task), None);
    }

    #[test]
    fn explicit_profile_submit_delay_is_preserved() {
        let p = profile(3, "OpenAI Codex CLI", "codex")
            .with_structured_adapter(StructuredAdapter::Codex)
            .with_submit_delay_ms(900);

        let submit = submit_config_for_profile(&p);

        assert_eq!(submit.delay_ms, Some(900));
    }

    #[test]
    fn active_wait_dependencies_are_transitive_and_deduplicated() {
        let edges = vec![
            (aid(1), aid(2)),
            (aid(2), aid(3)),
            (aid(1), aid(3)),
            (aid(9), aid(10)),
        ];

        let deps = OrchestratorService::active_wait_dependencies_from_edges(&edges, aid(1));

        assert_eq!(deps, vec![aid(3), aid(2)]);
    }

    // --- BusyTurnGuard (RAII de fin de tour) -------------------------------
    //
    // Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
    // qui enregistre les `mark_idle` et un mailbox qui enregistre les `cancel_head`.

    use std::sync::Mutex as TestMutex;

    #[derive(Default)]
    struct SpyMediator {
        idled: TestMutex<Vec<AgentId>>,
    }
    impl domain::input::InputMediator for SpyMediator {
        fn enqueue(
            &self,
            _agent: RuntimeAgentKey,
            _ticket: domain::mailbox::Ticket,
        ) -> domain::mailbox::PendingReply {
            domain::mailbox::PendingReply::new(Box::pin(async {
                Err(domain::mailbox::MailboxError::Cancelled)
            }))
        }
        fn preempt(&self, _agent: RuntimeAgentKey) {}
        fn mark_idle(&self, agent: RuntimeAgentKey) {
            self.idled.lock().unwrap().push(agent.agent_id);
        }
        fn busy_state(&self, _agent: RuntimeAgentKey) -> domain::input::AgentBusyState {
            domain::input::AgentBusyState::Idle
        }
    }

    #[derive(Default)]
    struct SpyMailbox {
        cancelled: TestMutex<Vec<(AgentId, TicketId)>>,
    }
    impl domain::mailbox::AgentMailbox for SpyMailbox {
        fn enqueue(
            &self,
            _agent: RuntimeAgentKey,
            _ticket: domain::mailbox::Ticket,
        ) -> domain::mailbox::PendingReply {
            domain::mailbox::PendingReply::new(Box::pin(async {
                Err(domain::mailbox::MailboxError::Cancelled)
            }))
        }
        fn resolve(
            &self,
            _agent: RuntimeAgentKey,
            _result: String,
        ) -> Result<(), domain::mailbox::MailboxError> {
            Ok(())
        }
        fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
            self.cancelled
                .lock()
                .unwrap()
                .push((agent.agent_id, ticket_id));
        }
    }

    fn aid(n: u128) -> AgentId {
        AgentId::from_uuid(uuid::Uuid::from_u128(n))
    }
    fn tid(n: u128) -> TicketId {
        TicketId::from_uuid(uuid::Uuid::from_u128(n))
    }
    fn rkey(n: u128) -> RuntimeAgentKey {
        RuntimeAgentKey::new(domain::ProjectId::from_uuid(uuid::Uuid::nil()), aid(n))
    }

    /// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le
    /// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
    #[test]
    fn armed_guard_drop_cancels_head_and_marks_idle() {
        let med = Arc::new(SpyMediator::default());
        let mb = Arc::new(SpyMailbox::default());
        {
            let _g = BusyTurnGuard::new(
                Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
                Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
                rkey(1),
                tid(7),
            );
        } // Drop ici.
        assert_eq!(
            *med.idled.lock().unwrap(),
            vec![aid(1)],
            "mark_idle au Drop"
        );
        assert_eq!(
            *mb.cancelled.lock().unwrap(),
            vec![(aid(1), tid(7))],
            "cancel_head au Drop"
        );
    }

    /// Garde **désarmé** (branche succès) ⇒ son Drop est un no-op : pas de `mark_idle`
    /// parasite, surtout pas de `cancel_head` d'un ticket déjà résolu.
    #[test]
    fn disarmed_guard_drop_is_a_noop() {
        let med = Arc::new(SpyMediator::default());
        let mb = Arc::new(SpyMailbox::default());
        let g = BusyTurnGuard::new(
            Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
            Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
            rkey(1),
            tid(7),
        );
        g.disarm();
        assert!(
            med.idled.lock().unwrap().is_empty(),
            "pas de mark_idle après disarm"
        );
        assert!(
            mb.cancelled.lock().unwrap().is_empty(),
            "pas de cancel_head après disarm"
        );
    }
}
