feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic

Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).

Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 08:56:17 +02:00
parent 40982d44da
commit 287681c198
57 changed files with 1758 additions and 420 deletions

View File

@ -15,7 +15,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::Mutex as AsyncMutex;
@ -26,7 +26,8 @@ use domain::mailbox::{Ticket, TicketId};
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath;
use domain::{
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId,
Project,
};
use crate::conversation::RecordTurn;
@ -34,7 +35,7 @@ use crate::conversation::RecordTurn;
use crate::agent::{
drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext,
UpdateAgentContextInput,
UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS,
};
use crate::error::AppError;
use crate::orchestrator::{
@ -51,6 +52,19 @@ const DEFAULT_ROWS: u16 = 24;
/// See [`DEFAULT_ROWS`].
const DEFAULT_COLS: u16 = 80;
/// 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)
}
/// 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
@ -152,6 +166,15 @@ impl Drop for BusyTurnGuard {
// 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,
self.ticket,
);
}
}
}
@ -939,16 +962,25 @@ impl OrchestratorService {
// le médiateur AVANT l'enqueue (consommé au start_turn).
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let pending = input.enqueue(agent_id, ticket);
// Rendezvous beacon (diagnostics) : l'ask est désormais en attente du
// `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en
// texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de
// « ask resolved » correspondant avant l'expiration du `turn_timeout` — la
// signature exacte du blocage Main→cible.
let started = Instant::now();
crate::diag!(
"[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \
ticket={ticket_id} cold_launch={cold_launch} gate_cold_start={gate_cold_start} \
turn_timeout_ms={}",
requester.map_or_else(|| "user".to_owned(), |a| a.to_string()),
turn_timeout.as_millis(),
);
// Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant
// `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur
// abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket
// fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]).
let busy_guard = BusyTurnGuard::new(
Arc::clone(input),
Arc::clone(mailbox),
agent_id,
ticket_id,
);
let busy_guard =
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
// turn into the bound handle). The service no longer writes the PTY directly —
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
@ -973,14 +1005,35 @@ impl OrchestratorService {
// qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket
// déjà résolu, ni libérer un busy state qui ne nous appartient plus.
busy_guard.disarm();
crate::diag!(
"[rendezvous] ask resolved: target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={} reply_len={}",
started.elapsed().as_millis(),
result.len(),
);
Ok(self.reply_outcome(agent_id, &target, result))
}
// Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au
// Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent).
Ok(Err(_cancelled)) => Err(AppError::Process(format!(
"agent {target} : canal de réponse fermé avant un résultat"
))),
Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)),
Ok(Err(_cancelled)) => {
crate::diag!(
"[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={}",
started.elapsed().as_millis(),
);
Err(AppError::Process(format!(
"agent {target} : canal de réponse fermé avant un résultat"
)))
}
Err(_elapsed) => {
crate::diag!(
"[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \
ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \
ni atteint son prompt-ready dans le turn_timeout)",
started.elapsed().as_millis(),
);
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
}
}
}
@ -1053,12 +1106,8 @@ impl OrchestratorService {
// 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_id,
ticket_id,
);
let busy_guard =
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
// Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le
// **même** garde-fou que le chemin PTY (profil ou défaut). On attend la
@ -1317,11 +1366,24 @@ impl OrchestratorService {
})?;
// 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).
match ticket {
let correlation = match ticket {
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
None => mailbox.resolve(from, 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()),
),
}
.map_err(|e| AppError::Invalid(e.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
@ -1773,7 +1835,7 @@ impl OrchestratorService {
else {
return (None, SubmitConfig::default(), false);
};
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
let submit = submit_config_for_profile(&profile);
// 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion
// (initialize) servira de signal de readiness de démarrage pour libérer un 1er
// tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`.
@ -1901,7 +1963,7 @@ fn normalise(s: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use domain::profile::{AgentProfile, ContextInjection};
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.
@ -1949,6 +2011,28 @@ mod tests {
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 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));
}
// --- BusyTurnGuard (RAII de fin de tour) -------------------------------
//
// Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur
@ -2026,7 +2110,11 @@ mod tests {
tid(7),
);
} // Drop ici.
assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop");
assert_eq!(
*med.idled.lock().unwrap(),
vec![aid(1)],
"mark_idle au Drop"
);
assert_eq!(
*mb.cancelled.lock().unwrap(),
vec![(aid(1), tid(7))],
@ -2047,7 +2135,10 @@ mod tests {
tid(7),
);
g.disarm();
assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après 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"