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

@ -37,8 +37,7 @@ use crate::dto::{
DeliveredDelegationRequestDto, DetectProfilesRequestDto, DetectProfilesResponseDto,
EffectivePermissionsDto, EmbedderEnginesDto, EmbedderProfileDto, EmbedderProfileListDto,
ErrorDto, FirstRunStateDto, FrontAttachedRequestDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto,
GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
@ -1217,8 +1216,8 @@ pub async fn launch_agent(
// est un raté best-effort connu pour LS7.
if let Some(parser) = &rate_limit_parser {
let text = String::from_utf8_lossy(&chunk);
if let Some(limit) =
parser.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
if let Some(limit) = parser
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
{
service.on_rate_limited(
agent_id,
@ -1378,10 +1377,7 @@ pub async fn agent_send(
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed agent id).
#[tauri::command]
pub async fn cancel_resume(
agent_id: String,
state: State<'_, AppState>,
) -> Result<bool, ErrorDto> {
pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result<bool, ErrorDto> {
let id = parse_agent_id(&agent_id)?;
Ok(state.session_limit_service.cancel_resume(id))
}

View File

@ -172,6 +172,16 @@ pub enum DomainEventDto {
/// badge the source.
source: OrchestrationSourceDto,
},
/// The project's orchestrator designation changed (T1).
#[serde(rename_all = "camelCase")]
OrchestratorChanged {
/// The project whose designation changed.
project_id: String,
/// The newly designated orchestrator agent, or `None` for the default
/// (the oldest agent orchestrates).
#[serde(skip_serializing_if = "Option::is_none")]
orchestrator: Option<String>,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
@ -401,6 +411,13 @@ impl From<&DomainEvent> for DomainEventDto {
ok: *ok,
source: (*source).into(),
},
DomainEvent::OrchestratorChanged {
project_id,
orchestrator,
} => Self::OrchestratorChanged {
project_id: project_id.to_string(),
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},

View File

@ -75,6 +75,12 @@ pub fn run() {
.path()
.app_data_dir()
.expect("failed to resolve the app data directory");
// Point the orchestrator's best-effort diagnostics at a persistent file
// (`<app-data>/logs/idea.log`) so inter-agent rendezvous beacons survive a
// click-launched AppImage (whose stderr is otherwise discarded). Best-effort:
// if the file can't be opened the beacons simply stay on stderr.
application::diag::set_log_path(app_data_dir.join("logs").join("idea.log"));
application::diag!("[startup] IdeA launched; diagnostics log armed");
let app_state = AppState::build(app_data_dir);
// Wire the domain event bus → Tauri events relay.

View File

@ -508,8 +508,14 @@ mod tests {
let out = String::from_utf8(cli_out).unwrap();
// Both request responses arrived; the notification produced none.
assert!(out.contains("\"id\":1"), "missing initialize response: {out}");
assert!(out.contains("\"id\":2"), "missing tools/list response: {out}");
assert!(
out.contains("\"id\":1"),
"missing initialize response: {out}"
);
assert!(
out.contains("\"id\":2"),
"missing tools/list response: {out}"
);
}
/// stdin EOF with no request ⇒ relay returns `Ok` (code 0), having still sent

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(),
})))