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

@ -122,6 +122,8 @@ pub struct CodexExecSession {
command: String,
/// Répertoire de travail (run dir isolé §14.1).
cwd: String,
/// Project/workspace roots that must be writable in Codex's CLI sandbox.
writable_roots: Vec<String>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
conversation_id: Mutex<Option<String>>,
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
@ -143,6 +145,7 @@ impl CodexExecSession {
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
writable_roots: Vec<String>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
@ -150,6 +153,7 @@ impl CodexExecSession {
id,
command: command.into(),
cwd: cwd.into(),
writable_roots,
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
sandbox_enforcer,
@ -160,18 +164,18 @@ impl CodexExecSession {
///
/// Format RÉEL vérifié 2026-06-10 (codex 0.137.0) :
/// - Conversation neuve : `codex exec --json --skip-git-repo-check
/// --sandbox workspace-write <prompt>`.
/// --sandbox workspace-write --add-dir <project-root> <prompt>`.
/// - Reprise (id connu) : `codex exec resume <thread_id> --json
/// --skip-git-repo-check --sandbox workspace-write <prompt>`.
/// --skip-git-repo-check --sandbox workspace-write --add-dir <project-root>
/// <prompt>`.
///
/// **Autonomie d'écriture (D3)** : `--sandbox workspace-write` autorise l'agent à
/// écrire dans son workspace. `codex exec` est déjà non-interactif (aucun prompt
/// d'approbation possible), donc on ne passe **pas** `--ask-for-approval` : ce flag
/// appartient à la commande interactive `codex`, pas à la sous-commande `exec` qui
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Défaut
/// raisonnable, aligné sur l'autonomie projet (CLAUDE.md §12) ; à terme **piloté par
/// les permissions de l'agent** (`.ideai/permissions.json` + sandbox OS) — non
/// implémenté ici.
/// sort sur `error: unexpected argument '--ask-for-approval' found`. Comme l'agent
/// tourne depuis son run dir isolé, `--add-dir` expose explicitement le project root
/// à la sandbox Codex pour que les écritures Git touchent le vrai workspace.
fn build_spawn_line(&self, prompt: &str) -> SpawnLine {
let mut args = vec!["exec".to_owned()];
if let Some(id) = self.conversation_id.lock().expect("mutex sain").as_ref() {
@ -182,6 +186,10 @@ impl CodexExecSession {
args.push("--skip-git-repo-check".to_owned());
args.push("--sandbox".to_owned());
args.push("workspace-write".to_owned());
for root in self.writable_roots.iter().filter(|root| !root.is_empty()) {
args.push("--add-dir".to_owned());
args.push(root.clone());
}
args.push(prompt.to_owned());
SpawnLine {
command: self.command.clone(),

View File

@ -79,7 +79,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
async fn start(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&SandboxPlan>,
@ -109,12 +109,18 @@ impl AgentSessionFactory for StructuredSessionFactory {
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
let session: Arc<dyn AgentSession> = match adapter {
StructuredAdapter::Claude => {
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
}
StructuredAdapter::Codex => {
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
}
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(
id, command, cwd, seed, plan, enforcer,
)),
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(
id,
command,
cwd,
seed,
vec![ctx.project_root.clone()],
plan,
enforcer,
)),
};
Ok(session)
}

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,

View File

@ -158,8 +158,7 @@ async fn run_turn_sandboxed(
// Thread JETABLE : sa restriction Landlock meurt avec lui.
std::thread::spawn(move || {
let result =
drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
let result = drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx);
// Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi.
let _ = done_tx.send(result);
});

View File

@ -187,7 +187,10 @@ async fn structured_run_turn_without_plan_does_not_sandbox() {
.expect("run_turn natif réussit");
assert_eq!(lines, vec![RESULT_LINE.to_owned()]);
assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)");
assert!(
allowed_marker.exists(),
"écriture in-grant réussit (sans plan)"
);
assert!(
denied_marker.exists(),
"sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale"
@ -278,7 +281,10 @@ async fn structured_run_turn_fail_closed_no_child_on_enforce_err() {
async fn structured_run_turn_none_plan_is_native_path() {
let dir = fresh_dir("p4");
let marker = dir.join("native.txt");
let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display());
let script = format!(
"printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'",
marker.display()
);
let spec = SpawnLine {
command: "sh".to_owned(),
args: vec!["-c".to_owned(), script],
@ -327,7 +333,10 @@ async fn structured_two_turns_disjoint_grants_are_confined() {
run_turn(&spec_a, None, Some(&enforcer))
.await
.expect("tour A ok");
assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir");
assert!(
a_in.exists(),
"tour A: écriture dans son grant (A) doit réussir"
);
assert!(
!a_into_b.exists(),
"tour A: écriture dans B (hors grant A) doit être bloquée"
@ -419,7 +428,9 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
use super::factory::StructuredSessionFactory;
if !landlock_is_enforced() {
eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible");
eprintln!(
"skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"
);
return;
}
@ -448,6 +459,7 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "CLAUDE.md".to_owned(),
project_root: run_dir.to_string_lossy().into_owned(),
};
let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu");
let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés
@ -466,7 +478,10 @@ async fn structured_sandboxed_turn_preserves_conversation_id() {
.iter()
.filter(|e| matches!(e, ReplyEvent::Final { .. }))
.count();
assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}");
assert_eq!(
finals, 1,
"un Final attendu malgré le sandbox, vu: {events:?}"
);
// LE POINT : l'id de conversation a bien été capté sous enforcement actif.
assert_eq!(