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

@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer {
conversation_id: Option<String>,
resume_prompt: &str,
) -> Result<(), AppError> {
self.calls
.lock()
.unwrap()
.push((agent_id, node_id, conversation_id, resume_prompt.to_owned()));
self.calls.lock().unwrap().push((
agent_id,
node_id,
conversation_id,
resume_prompt.to_owned(),
));
if self.fail.load(Ordering::SeqCst) {
Err(AppError::Internal("reprise échouée (fake)".to_owned()))
} else {
@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset");
assert!(
env.scheduler.armed().is_empty(),
"aucun arm sans heure de reset"
);
assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus");
assert_eq!(
env.bus.events(),
@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
// Deux arms (un par signal), ids distincts.
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)");
assert_eq!(
issued.len(),
2,
"deux arms (rafraîchissement, pas empilement)"
);
// Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal.
assert_eq!(
env.scheduler.cancels(),
@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
let env = env_at(NOW);
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let task = ScheduledTask::ResumeAgent {
agent_id: aid(1),
@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
// Le resumer a vu exactement l'appel attendu, avec le prompt constant.
assert_eq!(
env.resumer.calls(),
vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())]
vec![(
aid(1),
nid(2),
Some("conv-1".to_owned()),
RESUME_PROMPT.to_owned()
)]
);
// AgentResumed publié.
@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
.execute_resume(task)
.await
.expect_err("la reprise doit échouer");
assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}");
assert!(
matches!(err, AppError::Internal(_)),
"erreur propagée: {err:?}"
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumed { .. })),
@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
#[test]
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
let issued = env.scheduler.issued();
assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"cancel d'un réveil armé ⇒ true"
);
// Le bon ScheduleId a été passé au Scheduler.
assert_eq!(env.scheduler.cancels(), vec![issued[0]]);
// AgentResumeCancelled publié.
@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
fn cancel_resume_without_arm_is_false_no_event() {
let env = env_at(NOW);
assert!(!env.service.cancel_resume(aid(1)));
assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité");
assert!(
env.scheduler.cancels().is_empty(),
"Scheduler non sollicité"
);
assert!(env.bus.events().is_empty(), "aucun event");
}
@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() {
#[test]
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
// Simule un réveil déjà tiré : cancel renvoie false.
env.scheduler.set_cancel_result(false);
@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
assert_eq!(env.scheduler.cancels().len(), 1);
// AUCUN AgentResumeCancelled (pas d'event trompeur).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW);
let past = NOW - 30_000;
env.service
.confirm_human_resume(aid(1), nid(2), None, past);
env.service.confirm_human_resume(aid(1), nid(2), None, past);
let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1);
@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
#[test]
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (auto puis humain), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
// Aucun AgentResumeCancelled (dédoublonnage interne, silencieux).
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
);
// Unicité de l'armement actif : un seul cancel_resume aboutit.
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"
@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000));
env.service.on_rate_limited(
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 120_000),
);
let issued = env.scheduler.issued();
assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement");
assert_eq!(
issued.len(),
2,
"deux arms (humain puis auto), pas d'empilement"
);
assert_eq!(
env.scheduler.cancels(),
vec![issued[0]],
@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
);
assert!(
!env
.bus
!env.bus
.events()
.iter()
.any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })),
"le dédoublonnage croisé n'émet PAS AgentResumeCancelled"
);
assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true");
assert!(
env.service.cancel_resume(aid(1)),
"un armement actif unique ⇒ true"
);
assert!(
!env.service.cancel_resume(aid(1)),
"plus aucun armement après le premier cancel ⇒ false (une seule entrée)"