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

@ -62,6 +62,7 @@ mod tests {
PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
project_root: "/project".to_owned(),
}
}
@ -240,8 +241,7 @@ mod tests {
// turn.started / turn.completed ⇒ heartbeat (vivacité non terminale, lot 1).
let started = codex::parse_event(r#"{"type":"turn.started"}"#).expect("ok");
assert_eq!(started.events, vec![ReplyEvent::Heartbeat]);
let completed =
codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
let completed = codex::parse_event(r#"{"type":"turn.completed","usage":{}}"#).expect("ok");
assert_eq!(completed.events, vec![ReplyEvent::Heartbeat]);
let msg = codex::parse_event(
@ -310,6 +310,7 @@ mod tests {
fake.command(),
"/",
None,
Vec::new(),
None,
None,
));
@ -321,7 +322,14 @@ mod tests {
#[tokio::test]
async fn stream_is_closed_after_final() {
let fake = FakeCli::printing(&claude_script());
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let stream = session.send("x").await.expect("send ok");
let events: Vec<_> = stream.collect();
let after_final = events
@ -339,7 +347,14 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
"{ ceci n'est pas du json",
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
match session.send("x").await {
Err(AgentSessionError::Decode(_)) => {}
Err(other) => panic!("attendu Decode, vu: {other:?}"),
@ -437,6 +452,42 @@ mod tests {
assert_eq!(content_cx, "réponse Codex");
}
#[tokio::test]
async fn factory_passes_project_root_to_codex_add_dir() {
let factory = StructuredSessionFactory::new();
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let codex = structured_profile(StructuredAdapter::Codex, &cmd);
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "AGENTS.md".to_owned(),
project_root: "/project/root".to_owned(),
};
let session = factory
.start(&codex, &ctx, &cwd(), &SessionPlan::None, None)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
assert_eq!(content, "ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert!(
args.windows(2).any(|w| w == ["--add-dir", "/project/root"]),
"factory must relay PreparedContext.project_root to Codex --add-dir, got: {args:?}"
);
assert!(
!args.contains(&"--ask-for-approval"),
"codex exec must not receive unsupported approval flags, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn factory_resume_seeds_conversation_id() {
let factory = StructuredSessionFactory::new();
@ -564,10 +615,14 @@ mod tests {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut bin = std::env::temp_dir();
bin.push(format!("idea-rec-cli-{}-{n}", std::process::id()));
let mut argv = std::env::temp_dir();
argv.push(format!("idea-rec-argv-{}-{n}", std::process::id()));
let dir = std::env::current_dir()
.expect("cwd")
.join("target")
.join("test-fakes")
.join("session");
std::fs::create_dir_all(&dir).expect("create rec fake dir");
let bin = dir.join(format!("idea-rec-cli-{}-{n}", std::process::id()));
let argv = dir.join(format!("idea-rec-argv-{}-{n}", std::process::id()));
let mut s = String::from("#!/bin/sh\n");
// Enregistre chaque argument sur sa propre ligne dans le sidecar.
@ -775,7 +830,15 @@ mod tests {
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
r#"{"type":"turn.completed","usage":{}}"#,
]);
let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let s = CodexExecSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
Vec::new(),
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send").collect();
let finals = events
.iter()
@ -789,7 +852,10 @@ mod tests {
.skip(1)
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(after_final_terminals, 0, "aucun second Final après le premier");
assert_eq!(
after_final_terminals, 0,
"aucun second Final après le premier"
);
}
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
@ -803,7 +869,14 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
]);
let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let s = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<_> = s.send("x").await.expect("send ok").collect();
let finals = events
.iter()
@ -959,6 +1032,7 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
Vec::new(),
None,
None,
);
@ -983,7 +1057,8 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session =
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
assert_eq!(session.conversation_id(), None);
let _ = session.send("t1").await.expect("t1");
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
@ -1093,6 +1168,7 @@ mod tests {
fake.command(),
"/",
None,
Vec::new(),
None,
None,
));
@ -1123,7 +1199,14 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,
@ -1160,7 +1243,8 @@ mod tests {
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session =
ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let _ = session.send("bonjour").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1192,7 +1276,15 @@ mod tests {
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
None,
Vec::new(),
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1217,7 +1309,8 @@ mod tests {
// =====================================================================
// DURCISSEMENT QA (lot D3, §17.9 D3 — fix codex 0.137) — autonomie
// d'écriture Codex : la commande générée porte EXACTEMENT
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, <prompt>]
// [exec, --json, --skip-git-repo-check, --sandbox, workspace-write,
// --add-dir, <project-root>, <prompt>]
// (resume <id> en tête pour une reprise). Le flag `--ask-for-approval never`
// a été RETIRÉ : `codex exec` 0.137 ne le connaît pas (`error: unexpected
// argument`) et est déjà non-interactif. Ce test verrouille l'argv exact pour
@ -1226,15 +1319,23 @@ mod tests {
// =====================================================================
/// Conversation NEUVE : argv EXACT `[exec, --json, --skip-git-repo-check,
/// --sandbox, workspace-write, <prompt>]`. Pas de sous-commande `resume`,
/// pas de `--ask-for-approval`.
/// --sandbox, workspace-write, --add-dir, <project-root>, <prompt>]`.
/// Pas de sous-commande `resume`, pas de `--ask-for-approval`.
#[tokio::test]
async fn codex_new_conversation_command_carries_exact_args() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None);
let session = CodexExecSession::new(
SessionId::new_random(),
cmd.clone(),
"/",
None,
vec!["/project/root".to_owned()],
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
@ -1247,6 +1348,8 @@ mod tests {
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"salut",
],
"argv neuf doit être exact (sans resume, sans --ask-for-approval), vu: {args:?}"
@ -1256,8 +1359,8 @@ mod tests {
}
/// REPRISE (seed d'id) : argv EXACT `[exec, resume, <id>, --json,
/// --skip-git-repo-check, --sandbox, workspace-write, <prompt>]`. Toujours
/// pas de `--ask-for-approval`.
/// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, <project-root>,
/// <prompt>]`. Toujours pas de `--ask-for-approval`.
#[tokio::test]
async fn codex_resume_command_carries_exact_args() {
let (cmd, argv) = make_recording_fake(&[
@ -1268,6 +1371,7 @@ mod tests {
cmd.clone(),
"/",
Some("cx-id".to_owned()),
vec!["/project/root".to_owned()],
None,
None,
);
@ -1285,6 +1389,8 @@ mod tests {
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"vas-y",
],
"argv reprise doit être exact (resume <id> en tête, sans --ask-for-approval), vu: {args:?}"
@ -1339,13 +1445,19 @@ mod tests {
#[test]
fn parse_reset_ms_integer_seconds_are_scaled_to_ms() {
// < 10^12 ⇒ secondes ⇒ ×1000.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000_i64 })),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_reset_ms_integer_millis_are_kept_as_is() {
// ≥ 10^12 ⇒ déjà des millisecondes ⇒ tel quel.
assert_eq!(parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": 1_700_000_000_000_i64 })),
Some(1_700_000_000_000)
);
}
/// Le SEUIL exact (10^12) : juste en-dessous ⇒ secondes (×1000) ; pile/au-dessus
@ -1388,12 +1500,18 @@ mod tests {
#[test]
fn parse_reset_ms_string_integer_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000" })), Some(1_700_000_000_000));
assert_eq!(
parse_reset_ms(&json!({ "reset": "1700000000" })),
Some(1_700_000_000_000)
);
}
#[test]
fn parse_reset_ms_string_float_uses_seconds_heuristic() {
assert_eq!(parse_reset_ms(&json!({ "reset": "1700000000.5" })), Some(1_700_000_000_500));
assert_eq!(
parse_reset_ms(&json!({ "reset": "1700000000.5" })),
Some(1_700_000_000_500)
);
}
// ---- parse_reset_ms : ISO-8601 / RFC3339 (parseur maison) --------------
@ -1487,11 +1605,20 @@ mod tests {
// Pas de séparateur de date/heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14" })), None);
// Année non numérique.
assert_eq!(parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "abcd-11-14T00:00:00Z" })),
None
);
// Composante de date manquante (pas de jour).
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11T00:00:00Z" })),
None
);
// Trop de composantes de date.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })), None);
assert_eq!(
parse_reset_ms(&json!({ "reset": "2023-11-14-9T00:00:00Z" })),
None
);
// Minute manquante dans l'heure.
assert_eq!(parse_reset_ms(&json!({ "reset": "2023-11-14T22Z" })), None);
}
@ -1614,7 +1741,14 @@ mod tests {
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ap"}]},"session_id":"rl-1","parent_tool_use_id":null}"#,
r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"rl-1","num_turns":1}"#,
]);
let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None);
let session = ClaudeSdkSession::new(
SessionId::new_random(),
fake.command(),
"/",
None,
None,
None,
);
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
assert_eq!(
events,