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

@ -13,26 +13,25 @@ use std::sync::{Arc, Mutex};
use application::{
AgentResumer, AppError, AssignSkillToAgent, ChangeAgentProfile, CheckEmbedderSuggestion,
CloseProject, CloseTab,
CloseTerminal, ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch,
CreateAgentFromTemplate,
CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateTemplate, DeleteAgent,
LaunchAgentInput, ProposeContext, ReadContext, ReadMemory, SessionLimitService, WriteMemory,
DeleteEmbedderProfile, DeleteLayout, DeleteMemory, DeleteProfile, DeleteSkill, DeleteTemplate,
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
FirstRunState, GetMemory, GetProjectPermissions, GitBranches, GitCheckout, GitCommit, GitGraph,
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HealthUseCase, InspectConversation,
LaunchAgent, ListAgents, ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry,
LoadLayout, McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject,
PermissionProjectorRegistry,
OpenTerminal, OrchestratorService, ReadAgentContext, ReadMemoryIndex, ReadProjectContext,
RecallMemory, ReconcileLayouts, RecordTurn, RecordTurnProvider, ReferenceProfiles,
RenameLayout, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile,
SaveProfile, SetActiveLayout, SnapshotRunningAgents, StructuredSessions, SuggestedThisSession,
SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UpdateAgentContext,
UpdateAgentPermissions, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions,
UpdateSkill, UpdateTemplate, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
CloseProject, CloseTab, CloseTerminal, ConfigureProfiles, ContextGuardUseCases,
CreateAgentFromScratch, CreateAgentFromTemplate, CreateLayout, CreateMemory, CreateProject,
CreateSkill, CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteLayout, DeleteMemory,
DeleteProfile, DeleteSkill, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift,
DetectProfiles, DismissEmbedderSuggestion, FirstRunState, GetMemory, GetProjectPermissions,
GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage, GitStatus,
GitUnstage, HealthUseCase, InspectConversation, LaunchAgent, LaunchAgentInput, ListAgents,
ListAgentsInput, ListEmbedderProfiles, ListLayouts, ListMemories, ListProfiles, ListProjects,
ListResumableAgents, ListSkills, ListTemplates, LiveAgentRegistry, LoadLayout, McpRuntime,
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
OrchestratorService, PermissionProjectorRegistry, ProposeContext, ReadAgentContext,
ReadContext, ReadMemory, ReadMemoryIndex, ReadProjectContext, RecallMemory, ReconcileLayouts,
RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, ResizeTerminal,
ResolveAgentPermissions, ResolveMemoryLinks, SaveEmbedderProfile, SaveProfile,
SessionLimitService, SetActiveLayout, SnapshotRunningAgents, StructuredSessions,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent,
UpdateAgentContext, UpdateAgentPermissions, UpdateMemory, UpdateProjectContext,
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WriteMemory, WriteToTerminal,
AGENT_MEMORY_RECALL_BUDGET,
};
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSessionFactory, Clock, Embedder, EmbedderEnvInspector,
@ -50,16 +49,16 @@ use uuid::Uuid;
use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudePermissionProjector,
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector,
EmbedderEnvProbe, FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore,
FsHandoffStore, FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
ClaudeTranscriptInspector, CliAgentRuntime, CodexPermissionProjector, EmbedderEnvProbe,
FsConversationLog, FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore,
FsMemoryStore, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSkillStore, FsTemplateStore, Git2Repository,
HeuristicHandoffSummarizer, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, SystemClock,
SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator, VectorMemoryRecall,
DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED,
VECTOR_ONNX_ENABLED,
OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory,
SystemClock, SystemMillisClock, TokioBroadcastEventBus, TokioScheduler, UuidGenerator,
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
};
use crate::chat::ChatBridge;
@ -773,10 +772,8 @@ impl AppState {
// l'infrastructure, keyés par leur `ProjectorKey` via `with(...)`.
let permission_projectors = Arc::new(
PermissionProjectorRegistry::new()
.with(Arc::new(ClaudePermissionProjector)
as Arc<dyn domain::PermissionProjector>)
.with(Arc::new(CodexPermissionProjector)
as Arc<dyn domain::PermissionProjector>),
.with(Arc::new(ClaudePermissionProjector) as Arc<dyn domain::PermissionProjector>)
.with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>),
);
let launch_agent = Arc::new(
@ -1520,9 +1517,10 @@ fn claude_mcp_config_target(profile: &AgentProfile) -> Option<&str> {
/// Pendant Codex de [`migrate_claude_run_dir`] : répare le `config.toml` MCP isolé
/// du run dir (table `[mcp_servers.idea]`) pour que Codex, qui lit ses serveurs MCP
/// dans `$CODEX_HOME/config.toml`, voie les outils `idea_*` après un redémarrage.
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global), donc
/// ce fichier appartient entièrement à IdeA : il est régénéré (clobber) dès qu'un
/// runtime réel est disponible. Best-effort, idempotent.
/// `CODEX_HOME` est isolé au run dir au lancement (jamais le `~/.codex` global).
/// Le fichier reste co-géré : la migration répare la partie MCP/trust sans effacer
/// les clés de permission (`approval_policy`, `sandbox_mode`) écrites par le
/// projecteur Codex. Best-effort, idempotent.
async fn migrate_codex_run_dir(
project: &Project,
agent_id: &AgentId,
@ -1547,11 +1545,18 @@ async fn migrate_codex_run_dir(
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
});
migrate_codex_mcp_config(run_dir_path, profile, runtime.as_ref()).await
migrate_codex_mcp_config(
run_dir_path,
project.root.as_str(),
profile,
runtime.as_ref(),
)
.await
}
async fn migrate_codex_mcp_config(
run_dir: &Path,
project_root: &str,
profile: &AgentProfile,
runtime: Option<&McpRuntime>,
) -> Result<(), std::io::Error> {
@ -1564,15 +1569,23 @@ async fn migrate_codex_mcp_config(
return Ok(());
};
let toml_path = run_dir.join(target);
let desired = mcp_server_entry_toml(profile, Some(runtime));
let declaration = mcp_server_entry_toml(profile, Some(runtime));
// `config.toml` isolé = entièrement géré par IdeA ⇒ régénération (clobber) ;
// idempotent (no-op si le contenu est déjà à jour).
match tokio::fs::read_to_string(&toml_path).await {
Ok(existing) if existing == desired => return Ok(()),
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
// Ne jamais clobber le fichier complet ici : il porte aussi la projection de
// permissions Codex. On répare seulement la table MCP et les entrées trust.
let existing = match tokio::fs::read_to_string(&toml_path).await {
Ok(existing) => Some(existing),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(err),
};
let desired = codex_config_toml_for_migration(
existing.as_deref(),
&declaration,
run_dir.to_string_lossy().as_ref(),
project_root,
);
if existing.as_deref() == Some(desired.as_str()) {
return Ok(());
}
if let Some(parent) = toml_path.parent() {
tokio::fs::create_dir_all(parent).await?;
@ -1629,6 +1642,98 @@ fn mcp_server_entry_toml(profile: &AgentProfile, runtime: Option<&McpRuntime>) -
domain::McpServerWiring::new(command, args, transport).to_config_toml()
}
fn codex_config_toml_for_migration(
existing: Option<&str>,
mcp_declaration: &str,
run_dir: &str,
project_root: &str,
) -> String {
let mut text = replace_toml_table_block(
existing.unwrap_or_default(),
"mcp_servers.idea",
mcp_declaration.trim_end(),
);
text = ensure_codex_project_trust(&text, run_dir);
text = ensure_codex_project_trust(&text, project_root);
if !text.ends_with('\n') {
text.push('\n');
}
text
}
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
let header = format!("[{table}]");
let mut out = Vec::new();
let mut skipping = false;
let mut inserted = false;
for line in existing.lines() {
let trimmed = line.trim();
if trimmed == header {
if !inserted {
push_toml_block(&mut out, replacement);
inserted = true;
}
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
skipping = false;
}
if !skipping {
out.push(line.to_owned());
}
}
if !inserted {
if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
out.push(String::new());
}
push_toml_block(&mut out, replacement);
}
out.join("\n")
}
fn push_toml_block(out: &mut Vec<String>, block: &str) {
out.extend(block.lines().map(ToOwned::to_owned));
}
fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
if path.is_empty() {
return existing.to_owned();
}
let header = format!(r#"[projects.{}]"#, toml_quoted(path));
if existing.lines().any(|line| line.trim() == header) {
return existing.to_owned();
}
let mut text = existing.trim_end().to_owned();
if !text.is_empty() {
text.push_str("\n\n");
}
text.push_str(&header);
text.push_str("\ntrust_level = \"trusted\"\n");
text
}
fn toml_quoted(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
fn merge_claude_settings_json(existing: &str, project_root: &str) -> Option<Value> {
let mut doc = match serde_json::from_str::<Value>(existing) {
Ok(Value::Object(map)) => Value::Object(map),
@ -1846,7 +1951,7 @@ fn write_atomically(path: &Path, content: &str) -> std::io::Result<()> {
mod run_dir_migration_tests {
use super::{
claude_settings_seed_value, is_claude_mcp_profile, mcp_server_entry,
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir,
merge_claude_settings_json, merge_mcp_json, migrate_claude_run_dir, migrate_codex_run_dir,
};
use application::McpRuntimeProvider;
use domain::ids::{AgentId, ProfileId, ProjectId};
@ -1876,6 +1981,25 @@ mod run_dir_migration_tests {
))
}
fn codex_profile() -> AgentProfile {
AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(10)),
"OpenAI Codex CLI",
"codex",
Vec::new(),
ContextInjection::convention_file("AGENTS.md").unwrap(),
None,
"{agentRunDir}",
None,
)
.unwrap()
.with_structured_adapter(StructuredAdapter::Codex)
.with_mcp(McpCapability::new(
McpConfigStrategy::toml_config_home(".codex/config.toml", "CODEX_HOME").unwrap(),
McpTransport::Stdio,
))
}
fn runtime(agent_id: AgentId) -> application::McpRuntime {
application::McpRuntime {
exe: "/opt/IdeA.AppImage".to_owned(),
@ -2012,6 +2136,58 @@ mod run_dir_migration_tests {
let _ = std::fs::remove_dir_all(temp);
}
#[test]
fn reconcile_codex_run_dir_preserves_permissions_and_repairs_mcp_trust() {
let agent_id = AgentId::from_uuid(Uuid::from_u128(89));
let temp = std::env::temp_dir().join(format!("idea-codex-run-migrate-{}", Uuid::new_v4()));
let project_root = temp.join("project");
let run_dir = project_root.join(".ideai/run").join(agent_id.to_string());
std::fs::create_dir_all(run_dir.join(".codex")).unwrap();
std::fs::write(
run_dir.join(".codex/config.toml"),
r#"approval_policy = "never"
sandbox_mode = "workspace-write"
[mcp_servers.idea]
command = "stale"
args = ["mcp-server"]
transport = "stdio"
"#,
)
.unwrap();
let project = domain::Project::new(
ProjectId::from_uuid(Uuid::from_u128(1234)),
"demo",
domain::project::ProjectPath::new(project_root.to_string_lossy().into_owned()).unwrap(),
domain::remote::RemoteRef::local(),
1,
)
.unwrap();
let profile = codex_profile();
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
migrate_codex_run_dir(&project, &agent_id, &profile)
.await
.unwrap();
});
let config = std::fs::read_to_string(run_dir.join(".codex/config.toml")).unwrap();
assert!(config.contains(r#"approval_policy = "never""#));
assert!(config.contains(r#"sandbox_mode = "workspace-write""#));
assert!(config.contains("[mcp_servers.idea]"));
assert!(config.contains(r#"default_tools_approval_mode = "approve""#));
assert!(config.contains("tool_timeout_sec = 86400"));
assert!(!config.contains(r#"command = "stale""#));
assert!(config.contains(&format!(r#"[projects."{}"]"#, run_dir.to_string_lossy())));
assert!(config.contains(&format!(r#"[projects."{}"]"#, project.root.as_str())));
let _ = std::fs::remove_dir_all(temp);
}
#[test]
fn claude_profile_guard_matches_only_claude_mcp_profiles() {
assert!(is_claude_mcp_profile(&claude_profile()));
@ -2361,6 +2537,7 @@ mod mcp_serve_peer_tests {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))
@ -3123,11 +3300,11 @@ mod mcp_serve_peer_tests {
// traite ces commandes sans cette erreur — et le réfute sans le câblage.
// -----------------------------------------------------------------------
use application::{
ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory,
};
use application::{ContextGuardUseCases, ProposeContext, ReadContext, ReadMemory, WriteMemory};
use domain::conversation::ConversationParty;
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};
use domain::memory::{
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
use domain::ports::{Clock, MemoryError, MemoryStore};
use domain::OrchestratorCommand;
use infrastructure::RwFileGuard;
@ -3204,8 +3381,7 @@ mod mcp_serve_peer_tests {
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<FakeMemory>) {
let memory = Arc::new(FakeMemory::default());
let file_guard =
Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
let file_guard = Arc::new(RwFileGuard::new()) as Arc<dyn domain::fileguard::FileGuard>;
let context_guard = Arc::new(ContextGuardUseCases {
read_context: Arc::new(ReadContext::new(
Arc::clone(&file_guard),
@ -3571,6 +3747,7 @@ mod mcp_e2e_loopback_tests {
manifest: AgentManifest {
version: 1,
entries: Vec::new(),
orchestrator: None,
},
contents: HashMap::new(),
})))