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:
@ -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(),
|
||||
})))
|
||||
|
||||
Reference in New Issue
Block a user