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

@ -23,25 +23,25 @@ use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, MemoryError, MemoryQuery, MemoryRecall,
OutputStream, PermissionStore, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::permission::{
EffectivePermissions, PermissionProjection, PermissionProjector, Posture, ProjectedFile,
ProjectionContext, ProjectorKey,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
SessionStrategy, StructuredAdapter,
};
use domain::{PermissionSet, ProjectPermissions};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
use domain::{PermissionSet, ProjectPermissions};
use domain::{PtySize, SessionId, SkillId, SkillRef};
use uuid::Uuid;
@ -84,6 +84,7 @@ impl FakeContexts {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -1351,7 +1352,9 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
"first memory line exact format: {doc}"
);
assert!(
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
doc.contains(
"- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"
),
"second memory line exact format: {doc}"
);
// Recalled order preserved; section after the persona.
@ -2611,7 +2614,9 @@ impl PermissionProjector for FakeClaudeProjector {
/// Faithful Codex projector double: emits a co-owned `MergeToml` over the two
/// managed keys + the matching `--sandbox`/`--ask-for-approval` args, both derived
/// from the posture exactly like the real projector. `eff == None` ⇒ empty.
/// from the posture exactly like the real projector. Workspace-write postures also
/// add the project root as a writable directory (`--add-dir`). `eff == None` ⇒
/// empty.
struct FakeCodexProjector;
impl FakeCodexProjector {
@ -2631,14 +2636,23 @@ impl PermissionProjector for FakeCodexProjector {
fn project(
&self,
eff: Option<&EffectivePermissions>,
_ctx: &ProjectionContext,
ctx: &ProjectionContext,
) -> PermissionProjection {
let Some(eff) = eff else {
return PermissionProjection::empty();
};
let (sandbox, approval) = Self::modes(eff.fallback());
let contents =
format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let contents = format!("sandbox_mode = \"{sandbox}\"\napproval_policy = \"{approval}\"\n");
let mut args = vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
];
if sandbox == "workspace-write" {
args.push("--add-dir".to_owned());
args.push(ctx.project_root.to_owned());
}
PermissionProjection {
files: vec![ProjectedFile::MergeToml {
rel_path: ".codex/config.toml".to_owned(),
@ -2646,12 +2660,7 @@ impl PermissionProjector for FakeCodexProjector {
managed_keys: vec!["sandbox_mode".to_owned(), "approval_policy".to_owned()],
contents,
}],
args: vec![
"--sandbox".to_owned(),
sandbox.to_owned(),
"--ask-for-approval".to_owned(),
approval.to_owned(),
],
args,
env: Vec::new(),
}
}
@ -2741,8 +2750,11 @@ fn convention_file_plan() -> Option<ContextInjectionPlan> {
/// would not fire (here the convention file is GEMINI.md): the explicit field wins.
#[tokio::test]
async fn projection_selects_claude_from_explicit_projector_field() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2752,7 +2764,10 @@ async fn projection_selects_claude_from_explicit_projector_field() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(seeds.len(), 1, "the Claude projector ran (explicit key)");
@ -2766,8 +2781,14 @@ async fn projection_selects_claude_from_explicit_projector_field() {
/// Claude projector is selected.
#[tokio::test]
async fn projection_falls_back_to_claude_from_convention_file() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap());
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
);
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -2775,7 +2796,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CLAUDE_SEED_REL).len(),
@ -2789,7 +2813,10 @@ async fn projection_falls_back_to_claude_from_convention_file() {
#[tokio::test]
async fn projection_falls_back_to_codex_from_structured_adapter() {
let profile = codex_profile().with_structured_adapter(StructuredAdapter::Codex);
assert!(profile.projector.is_none(), "no explicit projector (legacy)");
assert!(
profile.projector.is_none(),
"no explicit projector (legacy)"
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2799,7 +2826,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert_eq!(
fs.writes_ending_with(CODEX_CONFIG_REL).len(),
@ -2821,7 +2851,10 @@ async fn projection_falls_back_to_codex_from_structured_adapter() {
/// no projection at all, even with a full registry and a posed policy.
#[tokio::test]
async fn projection_noop_for_unprojectable_profile() {
let profile = profile(pid(9), ContextInjection::convention_file("GEMINI.md").unwrap());
let profile = profile(
pid(9),
ContextInjection::convention_file("GEMINI.md").unwrap(),
);
let (launch, agent, fs, pty, _s) = launch_with_projection(
profile,
Some(ContextInjectionPlan::File {
@ -2831,7 +2864,10 @@ async fn projection_noop_for_unprojectable_profile() {
Some(perm_doc(Posture::Allow)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(fs.writes_ending_with(CLAUDE_SEED_REL).is_empty());
assert!(fs.writes_ending_with(CODEX_CONFIG_REL).is_empty());
@ -2849,8 +2885,11 @@ async fn projection_noop_for_unprojectable_profile() {
/// inversion vs. the MCP non-clobbering regime (which skips when the file exists).
#[tokio::test]
async fn claude_replace_seed_is_clobbered_on_relaunch() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, sessions) = launch_with_projection(
profile,
convention_file_plan(),
@ -2864,9 +2903,15 @@ async fn claude_replace_seed_is_clobbered_on_relaunch() {
fs.mark_existing(&seed_path);
// First launch, then simulate the agent exiting so a fresh relaunch is allowed.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let seeds = fs.writes_ending_with(CLAUDE_SEED_REL);
assert_eq!(
@ -2906,7 +2951,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
);
// First projection.
launch.execute(launch_input(agent.id)).await.expect("launch 1");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 1");
let first = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2915,8 +2963,14 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
.clone(),
)
.unwrap();
assert!(first.contains("user_key = \"keep-me\""), "unmanaged key preserved: {first}");
assert!(first.contains("[mcp_servers.idea]"), "unmanaged table preserved: {first}");
assert!(
first.contains("user_key = \"keep-me\""),
"unmanaged key preserved: {first}"
);
assert!(
first.contains("[mcp_servers.idea]"),
"unmanaged table preserved: {first}"
);
assert!(
first.contains("sandbox_mode = \"workspace-write\""),
"managed sandbox_mode upserted: {first}"
@ -2928,7 +2982,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
// Second projection (relaunch): managed keys are replaced in place, not dup'd.
sessions.remove(&sid(777));
launch.execute(launch_input(agent.id)).await.expect("launch 2");
launch
.execute(launch_input(agent.id))
.await
.expect("launch 2");
let second = String::from_utf8(
fs.writes_ending_with(CODEX_CONFIG_REL)
.last()
@ -2947,7 +3004,10 @@ async fn codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged() {
1,
"idempotent: no duplicate approval_policy: {second}"
);
assert!(second.contains("user_key = \"keep-me\""), "unmanaged key still preserved");
assert!(
second.contains("user_key = \"keep-me\""),
"unmanaged key still preserved"
);
}
// ---- (4) args/env fold into the spawned spec --------------------------------
@ -2966,18 +3026,34 @@ async fn codex_projection_folds_args_into_spawn_spec() {
Some(perm_doc(Posture::Ask)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let args = &pty.spawns()[0].args;
// Ask ⇒ workspace-write / on-request, in CLI order.
let pos = args
.windows(2)
.position(|w| w == ["--sandbox".to_owned(), "workspace-write".to_owned()]);
assert!(pos.is_some(), "expected --sandbox workspace-write in {args:?}");
assert!(
pos.is_some(),
"expected --sandbox workspace-write in {args:?}"
);
let pos2 = args
.windows(2)
.position(|w| w == ["--ask-for-approval".to_owned(), "on-request".to_owned()]);
assert!(pos2.is_some(), "expected --ask-for-approval on-request in {args:?}");
assert!(
pos2.is_some(),
"expected --ask-for-approval on-request in {args:?}"
);
let add_dir = args
.windows(2)
.position(|w| w == ["--add-dir".to_owned(), "/home/me/proj".to_owned()]);
assert!(
add_dir.is_some(),
"expected --add-dir project root in {args:?}"
);
}
// ---- (5) MCP decoupling — THE key case of the lot ---------------------------
@ -2998,13 +3074,19 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
// The sandbox config WAS written despite the absent MCP capability.
let cfg = fs.writes_ending_with(CODEX_CONFIG_REL);
assert_eq!(cfg.len(), 1, "sandbox config projected without MCP");
let toml = String::from_utf8(cfg[0].1.clone()).unwrap();
assert!(toml.contains("sandbox_mode = \"read-only\""), "Deny ⇒ read-only: {toml}");
assert!(
toml.contains("sandbox_mode = \"read-only\""),
"Deny ⇒ read-only: {toml}"
);
// And the args were folded too.
assert!(
pty.spawns()[0]
@ -3022,8 +3104,11 @@ async fn codex_sandbox_projected_without_any_mcp_capability() {
/// even with a posed policy.
#[tokio::test]
async fn no_registry_means_no_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3031,7 +3116,10 @@ async fn no_registry_means_no_projection() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3043,8 +3131,11 @@ async fn no_registry_means_no_projection() {
/// written, even though the registry IS wired.
#[tokio::test]
async fn no_policy_posed_means_empty_projection() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3052,7 +3143,10 @@ async fn no_policy_posed_means_empty_projection() {
Some(ProjectPermissions::default()), // project_defaults = None ⇒ resolve_for == None
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
assert!(
fs.writes_ending_with(CLAUDE_SEED_REL).is_empty(),
@ -3066,8 +3160,11 @@ async fn no_policy_posed_means_empty_projection() {
/// projector and is reflected in the produced Claude settings (`defaultMode=plan`).
#[tokio::test]
async fn resolved_deny_posture_reflected_as_plan_mode() {
let profile = profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap())
.with_projector(ProjectorKey::Claude);
let profile = profile(
pid(9),
ContextInjection::convention_file("CLAUDE.md").unwrap(),
)
.with_projector(ProjectorKey::Claude);
let (launch, agent, fs, _pty, _s) = launch_with_projection(
profile,
convention_file_plan(),
@ -3075,7 +3172,10 @@ async fn resolved_deny_posture_reflected_as_plan_mode() {
Some(perm_doc(Posture::Deny)),
);
launch.execute(launch_input(agent.id)).await.expect("launch");
launch
.execute(launch_input(agent.id))
.await
.expect("launch");
let seed = String::from_utf8(fs.writes_ending_with(CLAUDE_SEED_REL)[0].1.clone()).unwrap();
let json: serde_json::Value = serde_json::from_str(&seed).expect("valid settings JSON");