diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 196f221..8e75f54 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -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 { +pub async fn cancel_resume(agent_id: String, state: State<'_, AppState>) -> Result { let id = parse_agent_id(&agent_id)?; Ok(state.session_limit_service.cancel_resume(id)) } diff --git a/crates/app-tauri/src/events.rs b/crates/app-tauri/src/events.rs index 1dbce94..f31ff2e 100644 --- a/crates/app-tauri/src/events.rs +++ b/crates/app-tauri/src/events.rs @@ -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, + }, /// 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(), }, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index cb61d5e..c7bcd54 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -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 + // (`/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. diff --git a/crates/app-tauri/src/mcp_bridge.rs b/crates/app-tauri/src/mcp_bridge.rs index ccc3742..4304098 100644 --- a/crates/app-tauri/src/mcp_bridge.rs +++ b/crates/app-tauri/src/mcp_bridge.rs @@ -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 diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 3eb87e9..b5421ad 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -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) - .with(Arc::new(CodexPermissionProjector) - as Arc), + .with(Arc::new(ClaudePermissionProjector) as Arc) + .with(Arc::new(CodexPermissionProjector) as Arc), ); 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, 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 { let mut doc = match serde_json::from_str::(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, Arc) { let memory = Arc::new(FakeMemory::default()); - let file_guard = - Arc::new(RwFileGuard::new()) as Arc; + let file_guard = Arc::new(RwFileGuard::new()) as Arc; 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(), }))) diff --git a/crates/app-tauri/tests/session_limit_wiring.rs b/crates/app-tauri/tests/session_limit_wiring.rs index ae1e2d8..12fd076 100644 --- a/crates/app-tauri/tests/session_limit_wiring.rs +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -16,9 +16,9 @@ use std::time::Duration; use app_tauri_lib::state::AppState; use domain::events::DomainEvent; use domain::ids::NodeId; +use domain::ports::IdGenerator; use domain::AgentId; use infrastructure::UuidGenerator; -use domain::ports::IdGenerator; fn temp_path(tag: &str) -> PathBuf { let ids = UuidGenerator::new(); @@ -124,7 +124,10 @@ async fn set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session() { .structured_sessions .node_for_agent(&unknown) .or_else(|| state.terminal_sessions.node_for_agent(&unknown)); - assert!(resolved.is_none(), "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)"); + assert!( + resolved.is_none(), + "set_resume_at ⇒ NOT_FOUND (aucun armement orphelin)" + ); } /// Parité runtime du filet humain : `confirm_human_resume` (la délégation de @@ -156,8 +159,14 @@ async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() { _ => break, } } - assert!(saw_rate_limited, "AgentRateLimited relayed on the bus (humain)"); - assert!(saw_scheduled, "AgentResumeScheduled relayed on the bus (humain)"); + assert!( + saw_rate_limited, + "AgentRateLimited relayed on the bus (humain)" + ); + assert!( + saw_scheduled, + "AgentResumeScheduled relayed on the bus (humain)" + ); // Annulable par la même voie que l'auto. assert!( diff --git a/crates/application/src/agent/catalogue.rs b/crates/application/src/agent/catalogue.rs index f01d6e3..35d9022 100644 --- a/crates/application/src/agent/catalogue.rs +++ b/crates/application/src/agent/catalogue.rs @@ -24,6 +24,11 @@ use domain::profile::{ StructuredAdapter, }; +/// Codex's interactive TUI is sensitive to receiving a large pasted block and the +/// submit key too close together. Keep the default conservative so delegated +/// prompts are actually submitted instead of remaining in the input editor. +pub const CODEX_SUBMIT_DELAY_MS: u32 = 350; + /// A fixed UUID namespace used to derive stable ids for reference profiles. /// (Random-looking but constant; only its stability matters.) const REFERENCE_NAMESPACE: uuid::Uuid = uuid::uuid!("6f9b1d2a-7c34-4e58-9a1b-2c3d4e5f6a7b"); @@ -82,6 +87,7 @@ pub fn reference_profiles() -> Vec { .expect("codex reference profile is valid") .with_structured_adapter(StructuredAdapter::Codex) .with_projector(ProjectorKey::Codex) + .with_submit_delay_ms(CODEX_SUBMIT_DELAY_MS) .with_mcp(McpCapability::new( // Codex lit ses serveurs MCP dans `$CODEX_HOME/config.toml`, pas `.mcp.json` : // IdeA écrit ce TOML DANS le run dir et pointe `CODEX_HOME` dessus pour diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index f20c539..68b8bec 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -21,14 +21,14 @@ use domain::ports::{ StoreError, }; use domain::profile::{McpConfigStrategy, StructuredAdapter}; +use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use domain::{ Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, ConversationId, ConversationParty, DomainEvent, EffectivePermissions, Handoff, HandoffStore, ManifestEntry, - MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, - ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, - PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, + MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, PermissionProjector, ProfileId, Project, + ProjectPath, ProjectedFile, ProjectionContext, ProjectorKey, ProviderSessionStore, PtySize, + SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; -use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -1446,6 +1446,7 @@ impl LaunchAgent { let prepared = PreparedContext { content: content.clone(), relative_path: agent.context_path.clone(), + project_root: input.project.root.as_str().to_owned(), }; // 4a. Resolve the session intention (T4). The conversation id is a property // of the *cell*, not the PTY: the caller (which owns the layout) passes diff --git a/crates/application/src/agent/mod.rs b/crates/application/src/agent/mod.rs index 8f2389e..43d7867 100644 --- a/crates/application/src/agent/mod.rs +++ b/crates/application/src/agent/mod.rs @@ -22,7 +22,9 @@ pub use structured::{ drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome, }; -pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles}; +pub use catalogue::{ + reference_profile_id, reference_profiles, selectable_reference_profiles, CODEX_SUBMIT_DELAY_MS, +}; pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput}; pub use lifecycle::{ ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch, diff --git a/crates/application/src/agent/session_limit.rs b/crates/application/src/agent/session_limit.rs index 09e35ea..1b89546 100644 --- a/crates/application/src/agent/session_limit.rs +++ b/crates/application/src/agent/session_limit.rs @@ -35,8 +35,7 @@ use crate::error::AppError; /// `--resume` (via [`domain::ports::SessionPlan::Resume`]) porte déjà tout /// l'historique : ce prompt n'a qu'à **réamorcer** le tour, pas reconstruire le /// contexte. Volontairement neutre et model-agnostique. -pub const RESUME_PROMPT: &str = - "La limite de session est levée. Reprends là où tu t'étais arrêté."; +pub const RESUME_PROMPT: &str = "La limite de session est levée. Reprends là où tu t'étais arrêté."; /// Port applicatif de **reprise d'un agent** (frontière implémentée au composition /// root, LS7). Calqué sur les autres traits-passerelles de l'application @@ -161,7 +160,13 @@ impl SessionLimitService { conversation_id, } = plan_resume(now, &limit, conversation_id) { - self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, Some(resets_at_ms)); + self.arm_scheduled( + agent_id, + fire_at_ms, + node_id, + conversation_id, + Some(resets_at_ms), + ); } } @@ -199,9 +204,14 @@ impl SessionLimitService { conversation_id, }, ); - self.armed.lock().expect("session-limit mutex sain").insert(agent_id, id); - self.events - .publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms }); + self.armed + .lock() + .expect("session-limit mutex sain") + .insert(agent_id, id); + self.events.publish(DomainEvent::AgentResumeScheduled { + agent_id, + fire_at_ms, + }); } /// **(b) Exécution de la reprise.** Consomme une [`ScheduledTask::ResumeAgent`] @@ -255,7 +265,10 @@ impl SessionLimitService { }; if self.scheduler.cancel(id) { - self.armed.lock().expect("session-limit mutex sain").remove(&agent_id); + self.armed + .lock() + .expect("session-limit mutex sain") + .remove(&agent_id); self.events .publish(DomainEvent::AgentResumeCancelled { agent_id }); true diff --git a/crates/application/src/agent/structured.rs b/crates/application/src/agent/structured.rs index 8884118..b1226f0 100644 --- a/crates/application/src/agent/structured.rs +++ b/crates/application/src/agent/structured.rs @@ -14,8 +14,8 @@ use std::time::Duration; -use domain::input::InputMediator; use domain::ids::AgentId; +use domain::input::InputMediator; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent}; use domain::readiness::{ReadinessPolicy, ReadinessSignal}; @@ -178,16 +178,15 @@ async fn drain_bounded_events( on_signal: impl FnMut(ReadinessSignal), ) -> Result { match timeout { - Some(dur) => match tokio::time::timeout( - dur, - drain_to_final(session, prompt, on_event, on_signal), - ) - .await - { - Ok(result) => result, - // La session **reste vivante** : on ne `shutdown` rien ici (§17.1). - Err(_elapsed) => Err(AgentSessionError::Timeout), - }, + Some(dur) => { + match tokio::time::timeout(dur, drain_to_final(session, prompt, on_event, on_signal)) + .await + { + Ok(result) => result, + // La session **reste vivante** : on ne `shutdown` rien ici (§17.1). + Err(_elapsed) => Err(AgentSessionError::Timeout), + } + } None => drain_to_final(session, prompt, on_event, on_signal).await, } } @@ -317,7 +316,9 @@ mod tests { let session = FakeSession { events: vec![ ReplyEvent::TextDelta { text: "a".into() }, - ReplyEvent::ToolActivity { label: "lit".into() }, + ReplyEvent::ToolActivity { + label: "lit".into(), + }, ReplyEvent::Heartbeat, ReplyEvent::Final { content: "fini".into(), diff --git a/crates/application/src/diag.rs b/crates/application/src/diag.rs new file mode 100644 index 0000000..0a91463 --- /dev/null +++ b/crates/application/src/diag.rs @@ -0,0 +1,74 @@ +//! Lightweight, dependency-free diagnostics sink for the orchestrator rendezvous. +//! +//! The orchestrator already emits best-effort traces through `eprintln!` (see +//! [`crate::orchestrator`]). When IdeA is launched by **clicking the AppImage**, +//! that stderr is discarded — so an inter-agent block (an `ask` that never gets +//! its `idea_reply`) leaves no retrievable trace. This module mirrors those +//! traces to a **persistent log file** the user can hand back for diagnosis, +//! while keeping the exact same "zero new dependency, never breaks the +//! rendezvous" discipline: every write is best-effort and infallible. +//! +//! - [`set_log_path`] is called once by the composition root (`app-tauri`) with +//! `/logs/idea.log`. Until then (and in tests) writes go to stderr +//! only. +//! - [`diag`] (and the [`diag!`] macro) timestamps a line with epoch-millis — the +//! same clock as the conversation logs' `atMs` — and appends it to the file, +//! always also echoing to stderr so a terminal launch and the test suite still +//! see it. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The resolved log file path, set once at startup. `None` ⇒ stderr-only. +static LOG_PATH: OnceLock = OnceLock::new(); + +/// Serialises concurrent appends so interleaved rendezvous beacons stay on their +/// own lines (the orchestrator handles many connections in parallel). +static WRITE_LOCK: Mutex<()> = Mutex::new(()); + +/// Points the diagnostics sink at `path`, creating its parent directory. +/// +/// Idempotent and infallible: a second call (or a failure to create the +/// directory) is ignored — diagnostics must never break startup. Called by the +/// composition root once the app-data directory is known. +pub fn set_log_path(path: PathBuf) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = LOG_PATH.set(path); +} + +/// Current epoch-millis (same clock as the conversation logs' `atMs`). +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// Appends one timestamped diagnostic line. Best-effort and infallible: a missing +/// path or an I/O error is swallowed (the line still reaches stderr). Never call +/// in a hot loop — these are lifecycle beacons, not a metrics stream. +pub fn diag(msg: impl std::fmt::Display) { + let line = format!("{} {msg}", now_ms()); + // Always echo to stderr: keeps a terminal launch and the test suite working, + // and preserves the pre-existing `eprintln!` behaviour for these beacons. + eprintln!("{line}"); + if let Some(path) = LOG_PATH.get() { + let _guard = WRITE_LOCK.lock(); + if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(f, "{line}"); + } + } +} + +/// `diag!("...", ...)` — formats then forwards to [`diag`]. Mirrors `eprintln!`. +#[macro_export] +macro_rules! diag { + ($($arg:tt)*) => { + $crate::diag::diag(format!($($arg)*)) + }; +} diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 85bb602..d1bd563 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -13,6 +13,7 @@ pub mod agent; pub mod conversation; +pub mod diag; pub mod embedder; pub mod error; pub mod git; @@ -31,19 +32,19 @@ pub mod window; pub use agent::{ drain_with_readiness, drain_with_readiness_outcome, reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking, AgentResumer, ChangeAgentProfile, - ChangeAgentProfileInput, ChangeAgentProfileOutput, - ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, - CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, - DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, - FirstRunStateOutput, HandoffProvider, InspectConversation, InspectConversationInput, - InspectConversationOutput, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, - ListAgentsInput, ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents, - ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, - ProfileAvailability, + ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput, + ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, + DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles, + DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider, + InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent, + LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, + ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, + ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, RESUME_PROMPT, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, + RESUME_PROMPT, }; pub use conversation::RecordTurn; pub use embedder::{ diff --git a/crates/application/src/orchestrator/context_guard.rs b/crates/application/src/orchestrator/context_guard.rs index 1a6adc6..dfff2a0 100644 --- a/crates/application/src/orchestrator/context_guard.rs +++ b/crates/application/src/orchestrator/context_guard.rs @@ -26,7 +26,7 @@ use std::sync::Arc; use domain::conversation::ConversationParty; -use domain::fileguard::{FileGuard, GuardError, GuardedResource}; +use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource}; use domain::markdown::MarkdownDoc; use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType}; use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath}; @@ -135,9 +135,10 @@ impl ReadContext { /// Proposes new content for an IdeA-owned context under the [`FileGuard`]. /// /// For an **agent** context: a direct write under an exclusive write-lease. For the -/// **global** project context by a non-orchestrator: the guard returns -/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal* -/// (a file under `.ideai/proposals/`) — never an overwrite of the live context. +/// **global** project context by a non-orchestrator: the use case asks the domain +/// policy whether the requester may write directly; otherwise it materialises a +/// proposal (a file under `.ideai/proposals/`) — never an overwrite of the live +/// context. pub struct ProposeContext { guard: Arc, contexts: Arc, @@ -214,24 +215,23 @@ impl ProposeContext { Ok(ProposeOutcome::Written) } None => { - // Global project context: single-writer. Try to acquire the write - // lease; Forbidden ⇒ materialise a proposal instead of overwriting. - match self - .guard - .acquire_write(requester, GuardedResource::ProjectContext) - .await - { - Ok(_lease) => { - let path = join_root(&project, PROJECT_CONTEXT_FILE); - self.fs.write(&path, content.as_bytes()).await?; - Ok(ProposeOutcome::Written) - } - Err(GuardError::Forbidden) => { - let path = self.file_proposal(&project, requester, &content).await?; - Ok(ProposeOutcome::Proposed { path }) - } - Err(other) => Err(map_guard_err(other)), + // Global project context: single-writer. Authorization stays in the + // domain policy; the guard only serialises the eventual direct write. + let manifest = self.contexts.load_manifest(&project).await?; + let designation = manifest.orchestrator_designation(); + let resource = GuardedResource::ProjectContext; + if !may_write_directly(requester, &resource, &designation) { + let path = self.file_proposal(&project, requester, &content).await?; + return Ok(ProposeOutcome::Proposed { path }); } + let _lease = self + .guard + .acquire_write(requester, resource) + .await + .map_err(map_guard_err)?; + let path = join_root(&project, PROJECT_CONTEXT_FILE); + self.fs.write(&path, content.as_bytes()).await?; + Ok(ProposeOutcome::Written) } } } @@ -392,7 +392,7 @@ mod tests { use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::conversation::ConversationParty; - use domain::fileguard::{may_write_directly, ReadLease, WriteLease}; + use domain::fileguard::{ReadLease, WriteLease}; use domain::ports::{FsError, MemoryError, StoreError}; use domain::project::ProjectPath; use domain::{ProfileId, ProjectId, RemoteRef}; @@ -443,12 +443,9 @@ mod tests { } async fn acquire_write( &self, - who: ConversationParty, + _who: ConversationParty, res: GuardedResource, ) -> Result { - if !may_write_directly(who, &res) { - return Err(GuardError::Forbidden); - } let lock = self.lock_for(&res); Ok(WriteLease::new(Box::new(lock.write_owned().await))) } @@ -612,6 +609,7 @@ mod tests { Arc::new(FakeContexts { manifest: AgentManifest { version: 1, + orchestrator: None, entries: vec![ManifestEntry { agent_id: agent, name: name.to_owned(), diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index c6f7fbc..806b3b7 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex as StdMutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::Mutex as AsyncMutex; @@ -26,7 +26,8 @@ use domain::mailbox::{Ticket, TicketId}; use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle}; use domain::project::ProjectPath; use domain::{ - AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project, + AgentId, AgentProfile, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, + Project, }; use crate::conversation::RecordTurn; @@ -34,7 +35,7 @@ use crate::conversation::RecordTurn; use crate::agent::{ drain_with_readiness, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents, ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, - UpdateAgentContextInput, + UpdateAgentContextInput, CODEX_SUBMIT_DELAY_MS, }; use crate::error::AppError; use crate::orchestrator::{ @@ -51,6 +52,19 @@ const DEFAULT_ROWS: u16 = 24; /// See [`DEFAULT_ROWS`]. const DEFAULT_COLS: u16 = 80; +/// Submit defaults for delegated prompts, after applying profile-specific +/// compatibility fallbacks for existing saved profiles. +fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { + let delay_ms = profile.submit_delay_ms.or_else(|| { + matches!( + profile.structured_adapter, + Some(domain::profile::StructuredAdapter::Codex) + ) + .then_some(CODEX_SUBMIT_DELAY_MS) + }); + SubmitConfig::new(profile.submit_sequence.clone(), delay_ms) +} + /// Bound on the synchronous inter-agent rendezvous (`agent.message` → `AskAgent`). /// /// A target agent's turn can be long (reasoning + tool use), so the cap is @@ -152,6 +166,15 @@ impl Drop for BusyTurnGuard { // soit le chemin (erreur, timeout, drop). self.mailbox.cancel_head(self.agent, self.ticket); self.input.mark_idle(self.agent); + // Rendezvous beacon (diagnostics) : le garde a libéré une cible restée Busy + // (chemin erreur / timeout / futur ask abandonné). Si ce beacon apparaît + // sans « ask resolved », la cible n'a jamais répondu — c'est le scénario de + // blocage à diagnostiquer. + crate::diag!( + "[rendezvous] busy-guard freed target agent {} (ticket {})", + self.agent, + self.ticket, + ); } } } @@ -939,16 +962,25 @@ impl OrchestratorService { // le médiateur AVANT l'enqueue (consommé au start_turn). let turn_timeout = self.turn_timeout_for(project, agent_id).await; let pending = input.enqueue(agent_id, ticket); + // Rendezvous beacon (diagnostics) : l'ask est désormais en attente du + // `idea_reply` (ou prompt-ready) de la cible. Si la cible termine son tour en + // texte SANS appeler `idea_reply`, ce beacon « ask started » n'aura pas de + // « ask resolved » correspondant avant l'expiration du `turn_timeout` — la + // signature exacte du blocage Main→cible. + let started = Instant::now(); + crate::diag!( + "[rendezvous] ask started: requester={} -> target={target} (agent {agent_id}) \ + ticket={ticket_id} cold_launch={cold_launch} gate_cold_start={gate_cold_start} \ + turn_timeout_ms={}", + requester.map_or_else(|| "user".to_owned(), |a| a.to_string()), + turn_timeout.as_millis(), + ); // Garde RAII de fin de tour, armé JUSTE après l'enqueue (la cible est maintenant // `Busy`). Quel que soit le chemin de sortie — erreur, timeout, ou **futur // abandonné (drop)** — son `Drop` ramène la cible `Idle` et retire le ticket // fantôme de la FIFO. C'est le fix de la cause racine (cf. [`BusyTurnGuard`]). - let busy_guard = BusyTurnGuard::new( - Arc::clone(input), - Arc::clone(mailbox), - agent_id, - ticket_id, - ); + let busy_guard = + BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id); // Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the // turn into the bound handle). The service no longer writes the PTY directly — // no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1). @@ -973,14 +1005,35 @@ impl OrchestratorService { // qui a résolu le `pending`) ; on ne veut ni re-`cancel_head` un ticket // déjà résolu, ni libérer un busy state qui ne nous appartient plus. busy_guard.disarm(); + crate::diag!( + "[rendezvous] ask resolved: target={target} (agent {agent_id}) \ + ticket={ticket_id} after_ms={} reply_len={}", + started.elapsed().as_millis(), + result.len(), + ); Ok(self.reply_outcome(agent_id, &target, result)) } // Erreur / timeout : on laisse le garde faire `cancel_head` + `mark_idle` au // Drop (retrait des `cancel_head` redondants — `cancel_head` reste idempotent). - Ok(Err(_cancelled)) => Err(AppError::Process(format!( - "agent {target} : canal de réponse fermé avant un résultat" - ))), - Err(_elapsed) => Err(AppError::from(domain::ports::AgentSessionError::Timeout)), + Ok(Err(_cancelled)) => { + crate::diag!( + "[rendezvous] ask channel-closed: target={target} (agent {agent_id}) \ + ticket={ticket_id} after_ms={}", + started.elapsed().as_millis(), + ); + Err(AppError::Process(format!( + "agent {target} : canal de réponse fermé avant un résultat" + ))) + } + Err(_elapsed) => { + crate::diag!( + "[rendezvous] ask TIMEOUT: target={target} (agent {agent_id}) \ + ticket={ticket_id} after_ms={} (la cible n'a jamais appelé idea_reply \ + ni atteint son prompt-ready dans le turn_timeout)", + started.elapsed().as_millis(), + ); + Err(AppError::from(domain::ports::AgentSessionError::Timeout)) + } } } @@ -1053,12 +1106,8 @@ impl OrchestratorService { // toutes les sorties — `return Err` des bras du select, OU **futur abandonné // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est // le fix de la cause racine du blocage `Busy` à vie. - let busy_guard = BusyTurnGuard::new( - Arc::clone(input), - Arc::clone(mailbox), - agent_id, - ticket_id, - ); + let busy_guard = + BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id); // Drainer le tour structuré (le `Final` ⇒ contenu + `mark_idle`), borné par le // **même** garde-fou que le chemin PTY (profil ou défaut). On attend la @@ -1317,11 +1366,24 @@ impl OrchestratorService { })?; // Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ; // sinon repli sur la tête de file de l'émetteur (compat agents mono-fil). - match ticket { + let correlation = match ticket { Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), None => mailbox.resolve(from, result), + }; + // Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a + // corrélé à un ask en vol — un échec ici (« no matching ask ») signe une + // délégation déjà expirée/abandonnée ou un ticket erroné côté cible. + match &correlation { + Ok(()) => crate::diag!( + "[rendezvous] idea_reply correlated: from agent {from} ticket={}", + ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()), + ), + Err(e) => crate::diag!( + "[rendezvous] idea_reply UNMATCHED: from agent {from} ticket={} err={e}", + ticket.map_or_else(|| "head".to_owned(), |t| t.to_string()), + ), } - .map_err(|e| AppError::Invalid(e.to_string()))?; + correlation.map_err(|e| AppError::Invalid(e.to_string()))?; // Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the // emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO // advances to the next queued ticket. This is the deterministic OR signal that @@ -1773,7 +1835,7 @@ impl OrchestratorService { else { return (None, SubmitConfig::default(), false); }; - let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms); + let submit = submit_config_for_profile(&profile); // 3e élément : le profil cible déclare-t-il un pont MCP ? Si oui, sa connexion // (initialize) servira de signal de readiness de démarrage pour libérer un 1er // tour différé — d'où le gate cold-launch même sans `prompt_ready_pattern`. @@ -1901,7 +1963,7 @@ fn normalise(s: &str) -> String { #[cfg(test)] mod tests { use super::*; - use domain::profile::{AgentProfile, ContextInjection}; + use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::ProfileId; // (c) — timeouts pilotés par profil (lot 2) : `turn_timeout_ms` prime sur le défaut. @@ -1949,6 +2011,28 @@ mod tests { assert_eq!(p.id.to_string(), p.id.to_string()); } + #[test] + fn codex_submit_config_gets_conservative_delay_when_profile_omits_it() { + let p = profile(2, "OpenAI Codex CLI", "codex") + .with_structured_adapter(StructuredAdapter::Codex); + + let submit = submit_config_for_profile(&p); + + assert_eq!(submit.sequence, None); + assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS)); + } + + #[test] + fn explicit_profile_submit_delay_is_preserved() { + let p = profile(3, "OpenAI Codex CLI", "codex") + .with_structured_adapter(StructuredAdapter::Codex) + .with_submit_delay_ms(900); + + let submit = submit_config_for_profile(&p); + + assert_eq!(submit.delay_ms, Some(900)); + } + // --- BusyTurnGuard (RAII de fin de tour) ------------------------------- // // Fakes minimaux pour observer ce que le garde appelle à son Drop : un médiateur @@ -2026,7 +2110,11 @@ mod tests { tid(7), ); } // Drop ici. - assert_eq!(*med.idled.lock().unwrap(), vec![aid(1)], "mark_idle au Drop"); + assert_eq!( + *med.idled.lock().unwrap(), + vec![aid(1)], + "mark_idle au Drop" + ); assert_eq!( *mb.cancelled.lock().unwrap(), vec![(aid(1), tid(7))], @@ -2047,7 +2135,10 @@ mod tests { tid(7), ); g.disarm(); - assert!(med.idled.lock().unwrap().is_empty(), "pas de mark_idle après disarm"); + assert!( + med.idled.lock().unwrap().is_empty(), + "pas de mark_idle après disarm" + ); assert!( mb.cancelled.lock().unwrap().is_empty(), "pas de cancel_head après disarm" diff --git a/crates/application/tests/agent_inspect.rs b/crates/application/tests/agent_inspect.rs index 748d3bf..76d061c 100644 --- a/crates/application/tests/agent_inspect.rs +++ b/crates/application/tests/agent_inspect.rs @@ -42,12 +42,14 @@ impl FakeContexts { Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries: vec![ManifestEntry::from_agent(agent)], + orchestrator: None, }))) } fn empty() -> Self { Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }))) } } diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index 3b9988e..92513e0 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -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 { /// 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"); diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index defb033..3e4b313 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -76,6 +76,7 @@ impl FakeContexts { manifest: AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }, contents: HashMap::new(), saves: 0, @@ -1058,7 +1059,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() { "the orphan .claude seed must be removed; removed={:?}", f.fs.removed() ); - assert!(!f.fs.has_file(&seed_path), "the seed is gone from the FS state"); + assert!( + !f.fs.has_file(&seed_path), + "the seed is gone from the FS state" + ); // The relaunch projected the Codex sandbox config + args. assert!( @@ -1066,7 +1070,10 @@ async fn swap_claude_to_codex_removes_claude_seed_and_projects_codex() { "the relaunch must project the Codex config" ); let toml = String::from_utf8(f.fs.read_file(&codex_path).unwrap()).unwrap(); - assert!(toml.contains("sandbox_mode = \"workspace-write\""), "{toml}"); + assert!( + toml.contains("sandbox_mode = \"workspace-write\""), + "{toml}" + ); assert!( f.pty.last_spawn_args().contains(&"--sandbox".to_owned()), "sandbox args folded into the relaunch spawn: {:?}", @@ -1102,7 +1109,10 @@ async fn swap_claude_to_claude_does_not_remove_seed() { f.fs.removed() ); // It is still present (re-clobbered by the relaunch's projection). - assert!(f.fs.has_file(&seed_path), "the seed survives the same-family swap"); + assert!( + f.fs.has_file(&seed_path), + "the seed survives the same-family swap" + ); } /// (3) **Codex→Claude**: Codex owns no Replace file ⇒ nothing is removed on the @@ -1138,13 +1148,19 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() { f.fs.removed() ); // The co-owned Codex config is untouched by cleanup… - assert!(f.fs.has_file(&codex_path), "the .codex/config.toml is never deleted"); + assert!( + f.fs.has_file(&codex_path), + "the .codex/config.toml is never deleted" + ); assert!( !f.fs.removed().iter().any(|p| p == &codex_path), "the .codex/config.toml is not in the removed list" ); // …and the relaunch projected the new Claude seed. - assert!(f.fs.has_file(&seed_path), "the relaunch writes the Claude seed"); + assert!( + f.fs.has_file(&seed_path), + "the relaunch writes the Claude seed" + ); } /// (4a) **No-op (no registry)**: without a projector registry wired on the swap, @@ -1153,7 +1169,8 @@ async fn swap_codex_to_claude_removes_nothing_and_keeps_codex_config() { async fn swap_without_registry_removes_nothing() { let agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(1)); // The default fixture wires NO projector registry on the swap. - let f = fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await; + let f = + fixture_with_profiles(&agent, vec![claude_profile(pid(1)), codex_profile(pid(2))]).await; let seed_path = format!("{}/{CLAUDE_SEED_REL}", run_dir_of(&agent.id)); f.fs.put(&seed_path, b"{\"permissions\":{}}"); @@ -1251,7 +1268,8 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() { let run_dir = run_dir_of(&agent.id); f.fs.put(&format!("{run_dir}/{CLAUDE_SEED_REL}"), b"{}"); let pair = pair_uuid(); - f.handoffs.seed(&pair, "État au dernier tour : LP3-4.", Some("objectif")); + f.handoffs + .seed(&pair, "État au dernier tour : LP3-4.", Some("objectif")); let host = nid(1); seed_live_agent_session(&f.sessions, agent.id, host, sid(42)); @@ -1273,7 +1291,10 @@ async fn swap_with_cleanup_preserves_pair_id_and_handoff() { // …and the pair id was preserved on the persisted leaf (P8d invariant). let (conv, running) = leaf_state(&f.fs, host).expect("leaf persisted"); assert_eq!(conv.as_deref(), Some(pair.as_str()), "pair id preserved"); - assert!(!running, "engine running flag reset by invalidate_engine_link"); + assert!( + !running, + "engine running flag reset by invalidate_engine_link" + ); // …and the handoff (keyed by that pair id) was re-injected into the new engine's // convention file — proving the relaunch threaded the preserved pair id. (The diff --git a/crates/application/tests/drain_with_readiness_lot1.rs b/crates/application/tests/drain_with_readiness_lot1.rs index 28b58f7..701eaad 100644 --- a/crates/application/tests/drain_with_readiness_lot1.rs +++ b/crates/application/tests/drain_with_readiness_lot1.rs @@ -258,8 +258,9 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() { #[tokio::test] async fn send_error_is_propagated_and_no_mark_idle() { let agent = aid(5); - let session = - ScriptedSession::new(Script::Err(AgentSessionError::Decode("bad json".to_owned()))); + let session = ScriptedSession::new(Script::Err(AgentSessionError::Decode( + "bad json".to_owned(), + ))); let mediator = RecordingMediator::new(); let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; @@ -322,8 +323,14 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() { }; let mediator = RecordingMediator::new(); - let out = - drain_with_readiness(&session, "x", Some(Duration::from_millis(20)), &mediator, agent).await; + let out = drain_with_readiness( + &session, + "x", + Some(Duration::from_millis(20)), + &mediator, + agent, + ) + .await; assert_eq!(out, Err(AgentSessionError::Timeout)); assert_eq!( mediator.mark_idle_count(agent), diff --git a/crates/application/tests/list_resumable_agents.rs b/crates/application/tests/list_resumable_agents.rs index 74ec01b..49fd731 100644 --- a/crates/application/tests/list_resumable_agents.rs +++ b/crates/application/tests/list_resumable_agents.rs @@ -143,6 +143,7 @@ impl FakeContexts { manifest: Arc::new(Mutex::new(AgentManifest { version: 1, entries, + orchestrator: None, })), fail: false, } @@ -152,6 +153,7 @@ impl FakeContexts { manifest: Arc::new(Mutex::new(AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, })), fail: true, } diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index bb0f68e..b966d76 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -61,6 +61,7 @@ impl FakeContexts { manifest: AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }, contents: HashMap::new(), }))) diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 94c123e..1f31c49 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -21,7 +21,7 @@ use domain::project::ProjectPath; use application::{ reference_profile_id, reference_profiles, ConfigureProfiles, ConfigureProfilesInput, DeleteProfile, DeleteProfileInput, DetectProfiles, DetectProfilesInput, FirstRunState, - ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, + ListProfiles, ReferenceProfiles, SaveProfile, SaveProfileInput, CODEX_SUBMIT_DELAY_MS, }; // --------------------------------------------------------------------------- @@ -366,6 +366,11 @@ fn catalogue_has_expected_commands_and_injection() { target: "AGENTS.md".to_owned() } ); + assert_eq!( + by_command["codex"].submit_delay_ms, + Some(CODEX_SUBMIT_DELAY_MS), + "Codex TUI needs a conservative text→submit delay for delegated prompts" + ); assert_eq!( by_command["gemini"].context_injection, ContextInjection::ConventionFile { diff --git a/crates/application/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs index ef08e1b..3115630 100644 --- a/crates/application/tests/session_limit_service.rs +++ b/crates/application/tests/session_limit_service.rs @@ -133,10 +133,12 @@ impl AgentResumer for FakeResumer { conversation_id: Option, resume_prompt: &str, ) -> Result<(), AppError> { - self.calls - .lock() - .unwrap() - .push((agent_id, node_id, conversation_id, resume_prompt.to_owned())); + self.calls.lock().unwrap().push(( + agent_id, + node_id, + conversation_id, + resume_prompt.to_owned(), + )); if self.fail.load(Ordering::SeqCst) { Err(AppError::Internal("reprise échouée (fake)".to_owned())) } else { @@ -252,7 +254,10 @@ fn on_rate_limited_without_reset_is_human_fallback_no_arm() { env.service .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None); - assert!(env.scheduler.armed().is_empty(), "aucun arm sans heure de reset"); + assert!( + env.scheduler.armed().is_empty(), + "aucun arm sans heure de reset" + ); assert!(env.scheduler.cancels().is_empty(), "aucun cancel non plus"); assert_eq!( env.bus.events(), @@ -284,7 +289,11 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() { // Deux arms (un par signal), ids distincts. let issued = env.scheduler.issued(); - assert_eq!(issued.len(), 2, "deux arms (rafraîchissement, pas empilement)"); + assert_eq!( + issued.len(), + 2, + "deux arms (rafraîchissement, pas empilement)" + ); // Le premier id émis a été annulé par le dédoublonnage du 2ᵉ signal. assert_eq!( env.scheduler.cancels(), @@ -316,8 +325,12 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() { async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() { let env = env_at(NOW); // Arme d'abord (pour prouver que l'entrée est ensuite retirée). - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000)); + env.service.on_rate_limited( + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(NOW + 60_000), + ); let task = ScheduledTask::ResumeAgent { agent_id: aid(1), @@ -329,7 +342,12 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() { // Le resumer a vu exactement l'appel attendu, avec le prompt constant. assert_eq!( env.resumer.calls(), - vec![(aid(1), nid(2), Some("conv-1".to_owned()), RESUME_PROMPT.to_owned())] + vec![( + aid(1), + nid(2), + Some("conv-1".to_owned()), + RESUME_PROMPT.to_owned() + )] ); // AgentResumed publié. @@ -364,11 +382,13 @@ async fn execute_resume_propagates_error_without_emitting_resumed() { .execute_resume(task) .await .expect_err("la reprise doit échouer"); - assert!(matches!(err, AppError::Internal(_)), "erreur propagée: {err:?}"); + assert!( + matches!(err, AppError::Internal(_)), + "erreur propagée: {err:?}" + ); assert!( - !env - .bus + !env.bus .events() .iter() .any(|e| matches!(e, DomainEvent::AgentResumed { .. })), @@ -385,11 +405,18 @@ async fn execute_resume_propagates_error_without_emitting_resumed() { #[test] fn cancel_resume_after_arm_returns_true_and_emits_cancelled() { let env = env_at(NOW); - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000)); + env.service.on_rate_limited( + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(NOW + 60_000), + ); let issued = env.scheduler.issued(); - assert!(env.service.cancel_resume(aid(1)), "cancel d'un réveil armé ⇒ true"); + assert!( + env.service.cancel_resume(aid(1)), + "cancel d'un réveil armé ⇒ true" + ); // Le bon ScheduleId a été passé au Scheduler. assert_eq!(env.scheduler.cancels(), vec![issued[0]]); // AgentResumeCancelled publié. @@ -408,7 +435,10 @@ fn cancel_resume_after_arm_returns_true_and_emits_cancelled() { fn cancel_resume_without_arm_is_false_no_event() { let env = env_at(NOW); assert!(!env.service.cancel_resume(aid(1))); - assert!(env.scheduler.cancels().is_empty(), "Scheduler non sollicité"); + assert!( + env.scheduler.cancels().is_empty(), + "Scheduler non sollicité" + ); assert!(env.bus.events().is_empty(), "aucun event"); } @@ -417,8 +447,12 @@ fn cancel_resume_without_arm_is_false_no_event() { #[test] fn cancel_resume_when_scheduler_already_fired_is_false_no_event() { let env = env_at(NOW); - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000)); + env.service.on_rate_limited( + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(NOW + 60_000), + ); // Simule un réveil déjà tiré : cancel renvoie false. env.scheduler.set_cancel_result(false); @@ -430,8 +464,7 @@ fn cancel_resume_when_scheduler_already_fired_is_false_no_event() { assert_eq!(env.scheduler.cancels().len(), 1); // AUCUN AgentResumeCancelled (pas d'event trompeur). assert!( - !env - .bus + !env.bus .events() .iter() .any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })), @@ -491,8 +524,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() { fn confirm_human_resume_past_reset_clamps_fire_at_to_now() { let env = env_at(NOW); let past = NOW - 30_000; - env.service - .confirm_human_resume(aid(1), nid(2), None, past); + env.service.confirm_human_resume(aid(1), nid(2), None, past); let armed = env.scheduler.armed(); assert_eq!(armed.len(), 1); @@ -521,13 +553,21 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() { #[test] fn confirm_human_resume_after_auto_dedups_single_active_arm() { let env = env_at(NOW); - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 60_000)); + env.service.on_rate_limited( + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(NOW + 60_000), + ); env.service .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000); let issued = env.scheduler.issued(); - assert_eq!(issued.len(), 2, "deux arms (auto puis humain), pas d'empilement"); + assert_eq!( + issued.len(), + 2, + "deux arms (auto puis humain), pas d'empilement" + ); assert_eq!( env.scheduler.cancels(), vec![issued[0]], @@ -536,8 +576,7 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() { // Aucun AgentResumeCancelled (dédoublonnage interne, silencieux). assert!( - !env - .bus + !env.bus .events() .iter() .any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })), @@ -545,7 +584,10 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() { ); // Unicité de l'armement actif : un seul cancel_resume aboutit. - assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true"); + assert!( + env.service.cancel_resume(aid(1)), + "un armement actif unique ⇒ true" + ); assert!( !env.service.cancel_resume(aid(1)), "plus aucun armement après le premier cancel ⇒ false (une seule entrée)" @@ -560,11 +602,19 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() { let env = env_at(NOW); env.service .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(NOW + 120_000)); + env.service.on_rate_limited( + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(NOW + 120_000), + ); let issued = env.scheduler.issued(); - assert_eq!(issued.len(), 2, "deux arms (humain puis auto), pas d'empilement"); + assert_eq!( + issued.len(), + 2, + "deux arms (humain puis auto), pas d'empilement" + ); assert_eq!( env.scheduler.cancels(), vec![issued[0]], @@ -572,15 +622,17 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() { ); assert!( - !env - .bus + !env.bus .events() .iter() .any(|e| matches!(e, DomainEvent::AgentResumeCancelled { .. })), "le dédoublonnage croisé n'émet PAS AgentResumeCancelled" ); - assert!(env.service.cancel_resume(aid(1)), "un armement actif unique ⇒ true"); + assert!( + env.service.cancel_resume(aid(1)), + "un armement actif unique ⇒ true" + ); assert!( !env.service.cancel_resume(aid(1)), "plus aucun armement après le premier cancel ⇒ false (une seule entrée)" diff --git a/crates/application/tests/skill_usecases.rs b/crates/application/tests/skill_usecases.rs index 3aa0b66..0b37e3a 100644 --- a/crates/application/tests/skill_usecases.rs +++ b/crates/application/tests/skill_usecases.rs @@ -91,6 +91,7 @@ impl FakeContexts { Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries, + orchestrator: None, }))) } fn manifest(&self) -> AgentManifest { diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 45d7738..cf13aae 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -73,6 +73,7 @@ impl FakeContexts { manifest: AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }, contents: HashMap::new(), }))); @@ -1111,6 +1112,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { let ctx = PreparedContext { content: MarkdownDoc::new("# persona"), relative_path: "agents/backend.md".to_owned(), + project_root: ROOT.to_owned(), }; let cwd = ProjectPath::new(ROOT).unwrap(); let session = f diff --git a/crates/application/tests/template_usecases.rs b/crates/application/tests/template_usecases.rs index d76e8aa..a22e241 100644 --- a/crates/application/tests/template_usecases.rs +++ b/crates/application/tests/template_usecases.rs @@ -72,6 +72,7 @@ impl FakeContexts { AgentManifest { version: 1, entries, + orchestrator: None, }, HashMap::new(), )))) diff --git a/crates/domain/src/agent.rs b/crates/domain/src/agent.rs index 46d6a13..a63b593 100644 --- a/crates/domain/src/agent.rs +++ b/crates/domain/src/agent.rs @@ -277,24 +277,59 @@ impl ManifestEntry { /// In-memory image of `.ideai/agents.json`. /// -/// Invariant enforced here: `md_path` values are unique across entries. +/// Invariants enforced here: +/// - `md_path` values are unique across entries; +/// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential). +/// +/// **Ordering invariant**: `entries` are kept in **creation order**, so +/// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the +/// vector position *is* the chronology, which is why the default orchestrator (when +/// none is designated) is `entries.first()`. #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentManifest { /// Schema version of the manifest file. pub version: u32, - /// Entries (one per project agent). + /// Entries (one per project agent), in **creation order** (oldest first). #[serde(rename = "agents")] pub entries: Vec, + /// Explicitly designated orchestrator agent, if any. + /// + /// We persist only the **deviation** from the default: `None` (the common case) + /// means « the oldest agent orchestrates » (see [`effective_orchestrator`]); + /// `Some(id)` is an explicit designation. Skipped on serialisation when `None`, + /// so pre-feature manifests round-trip unchanged (free backward compatibility). + /// + /// [`effective_orchestrator`]: AgentManifest::effective_orchestrator + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orchestrator: Option, } impl AgentManifest { - /// Builds a validated manifest. + /// Builds a validated manifest with **no** explicit orchestrator (the default: + /// the oldest agent orchestrates — see [`effective_orchestrator`]). + /// + /// [`effective_orchestrator`]: AgentManifest::effective_orchestrator /// /// # Errors /// Returns [`DomainError::InconsistentManifest`] if two entries share the /// same `md_path`. pub fn new(version: u32, entries: Vec) -> Result { + Self::with_orchestrator(version, entries, None) + } + + /// Builds a validated manifest carrying an explicit `orchestrator` designation. + /// + /// # Errors + /// - [`DomainError::InconsistentManifest`] if two entries share the same + /// `md_path`; + /// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while + /// `id` is **not** present in `entries` (referential integrity). + pub fn with_orchestrator( + version: u32, + entries: Vec, + orchestrator: Option, + ) -> Result { let mut seen = std::collections::HashSet::new(); for entry in &entries { if !seen.insert(entry.md_path.as_str()) { @@ -303,6 +338,196 @@ impl AgentManifest { }); } } - Ok(Self { version, entries }) + if let Some(id) = orchestrator { + if !entries.iter().any(|e| e.agent_id == id) { + return Err(DomainError::InconsistentManifest { + reason: format!("designated orchestrator `{id}` is not a known agent"), + }); + } + } + Ok(Self { + version, + entries, + orchestrator, + }) + } + + /// The **effective** orchestrator agent: the explicit designation if any, + /// otherwise the **oldest** agent (`entries.first()`, by the creation-order + /// invariant). `None` only when the manifest has no entries at all. + #[must_use] + pub fn effective_orchestrator(&self) -> Option { + self.orchestrator + .or_else(|| self.entries.first().map(|e| e.agent_id)) + } + + /// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the + /// [`OrchestratorDesignation`] value object consumed by the FileGuard policy. + #[must_use] + pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation { + match self.effective_orchestrator() { + Some(id) => crate::fileguard::OrchestratorDesignation::of(id), + None => crate::fileguard::OrchestratorDesignation::none(), + } + } + + /// Designates `id` as the project's orchestrator (radio semantics: overwrites any + /// previous designation). + /// + /// # Errors + /// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this + /// manifest. + pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> { + if !self.entries.iter().any(|e| e.agent_id == id) { + return Err(DomainError::InconsistentManifest { + reason: format!("cannot designate unknown agent `{id}` as orchestrator"), + }); + } + self.orchestrator = Some(id); + Ok(()) + } + + /// Reacts to the removal of an agent: if it was the **explicitly designated** + /// orchestrator, clears the designation so the manifest falls back to the default + /// (lazy succession — the next-oldest agent becomes effective). A no-op when the + /// removed agent was not the designated one. + /// + /// Pure bookkeeping: it does **not** remove the entry itself (the caller owns + /// `entries`); it only keeps the `orchestrator` deviation consistent. + pub fn on_agent_deleted(&mut self, removed: AgentId) { + if self.orchestrator == Some(removed) { + self.orchestrator = None; + } + } +} + +#[cfg(test)] +mod orchestrator_tests { + use super::*; + use crate::conversation::ConversationParty; + use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation}; + + fn agent_id(n: u128) -> AgentId { + AgentId::from_uuid(uuid::Uuid::from_u128(n)) + } + + /// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries + /// stay unique. + fn entry(n: u128) -> ManifestEntry { + ManifestEntry::new( + agent_id(n), + format!("agent-{n}"), + format!("agents/agent-{n}.md"), + ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)), + None, + false, + None, + ) + .unwrap() + } + + #[test] + fn default_orchestrator_is_oldest_agent_when_none_designated() { + // entries are in creation order: first is the oldest → the effective default. + let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap(); + assert_eq!(m.orchestrator, None); + assert_eq!(m.effective_orchestrator(), Some(agent_id(1))); + assert_eq!( + m.orchestrator_designation(), + OrchestratorDesignation::of(agent_id(1)) + ); + } + + #[test] + fn empty_manifest_has_no_effective_orchestrator() { + let m = AgentManifest::new(1, vec![]).unwrap(); + assert_eq!(m.effective_orchestrator(), None); + assert_eq!( + m.orchestrator_designation(), + OrchestratorDesignation::none() + ); + } + + #[test] + fn explicit_designation_overrides_oldest() { + let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); + m.designate(agent_id(2)).unwrap(); + assert_eq!(m.orchestrator, Some(agent_id(2))); + assert_eq!(m.effective_orchestrator(), Some(agent_id(2))); + } + + #[test] + fn designate_is_radio_and_rejects_unknown_agent() { + let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); + m.designate(agent_id(1)).unwrap(); + // Radio: a second designation overwrites the first (never accumulates). + m.designate(agent_id(2)).unwrap(); + assert_eq!(m.orchestrator, Some(agent_id(2))); + // Referential: designating an agent absent from `entries` is rejected. + let err = m.designate(agent_id(99)).unwrap_err(); + assert!(matches!(err, DomainError::InconsistentManifest { .. })); + // …and the previous valid designation is untouched. + assert_eq!(m.orchestrator, Some(agent_id(2))); + } + + #[test] + fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() { + let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap(); + m.designate(agent_id(3)).unwrap(); + // The designated agent is removed → designation cleared, default takes over. + m.entries.retain(|e| e.agent_id != agent_id(3)); + m.on_agent_deleted(agent_id(3)); + assert_eq!(m.orchestrator, None); + // Falls back to the (now) oldest remaining agent. + assert_eq!(m.effective_orchestrator(), Some(agent_id(1))); + } + + #[test] + fn on_agent_deleted_is_noop_for_non_designated_agent() { + let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); + m.designate(agent_id(2)).unwrap(); + m.on_agent_deleted(agent_id(1)); + // Deleting a non-designated agent leaves the designation intact. + assert_eq!(m.orchestrator, Some(agent_id(2))); + } + + #[test] + fn constructor_enforces_referential_integrity_of_orchestrator() { + // Some(id) with id present in entries → ok. + assert!( + AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),) + .is_ok() + ); + // Some(id) with id absent → rejected. + let err = + AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err(); + assert!(matches!(err, DomainError::InconsistentManifest { .. })); + // None is always valid (even with an empty manifest). + assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok()); + } + + #[test] + fn designated_agent_may_write_project_context_via_manifest_designation() { + let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap(); + m.designate(agent_id(1)).unwrap(); + let d = m.orchestrator_designation(); + // The designated agent writes the project context directly… + assert!(may_write_directly( + ConversationParty::agent(agent_id(1)), + &GuardedResource::ProjectContext, + &d, + )); + // …another agent is refused (single-writer preserved)… + assert!(!may_write_directly( + ConversationParty::agent(agent_id(2)), + &GuardedResource::ProjectContext, + &d, + )); + // …and the human is always allowed. + assert!(may_write_directly( + ConversationParty::User, + &GuardedResource::ProjectContext, + &d, + )); } } diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 7c344e2..f1d100e 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -258,6 +258,19 @@ pub enum DomainEvent { /// sinon `None` (l'utilisateur fournira l'heure). resets_at_ms: Option, }, + /// The project's **orchestrator designation** changed (cadrage « orchestrateur + /// du projet », T1). Emitted when an agent is designated (radio selection) or the + /// designation is cleared back to the default (e.g. the designated agent was + /// deleted — lazy succession to the oldest agent). Relayed to the front so the UI + /// can move the radio / refresh the orchestrator badge. Model-agnostic: carries + /// only the neutral fact « who orchestrates now ». + OrchestratorChanged { + /// The project whose designation changed. + project_id: ProjectId, + /// The newly designated orchestrator agent, or `None` for the default + /// (the oldest agent orchestrates). + orchestrator: Option, + }, /// Raw PTY output (usually routed to a dedicated channel, not this bus). PtyOutput { /// The session. @@ -358,6 +371,30 @@ mod tests { ); } + #[test] + fn orchestrator_changed_constructs_and_compares() { + let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100)); + let ev = DomainEvent::OrchestratorChanged { + project_id: project, + orchestrator: Some(agent(1)), + }; + assert_eq!( + ev, + DomainEvent::OrchestratorChanged { + project_id: project, + orchestrator: Some(agent(1)), + } + ); + // Clearing to the default (None) is a distinct event. + assert_ne!( + ev, + DomainEvent::OrchestratorChanged { + project_id: project, + orchestrator: None, + } + ); + } + #[test] fn distinct_session_limit_variants_are_not_equal() { // Les variantes ne se confondent pas entre elles malgré des champs proches. diff --git a/crates/domain/src/fileguard.rs b/crates/domain/src/fileguard.rs index 1fa7c59..30453bd 100644 --- a/crates/domain/src/fileguard.rs +++ b/crates/domain/src/fileguard.rs @@ -159,30 +159,71 @@ pub trait FileGuard: Send + Sync { ) -> Result; } +/// **Which agent (if any) the project designates as its orchestrator.** +/// +/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du +/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator +/// and is never represented here. +/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator. +/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio +/// selection, single agent by construction — the illegal "two orchestrators" state +/// is unrepresentable). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OrchestratorDesignation(Option); + +impl OrchestratorDesignation { + /// No agent designated: only the human ([`ConversationParty::User`]) orchestrates. + #[must_use] + pub const fn none() -> Self { + Self(None) + } + + /// Designates `agent` as the project's orchestrator (radio selection). + #[must_use] + pub const fn of(agent: AgentId) -> Self { + Self(Some(agent)) + } + + /// The designated agent, if any. `None` ⇒ default (human-only orchestrator). + #[must_use] + pub const fn designated(&self) -> Option { + self.0 + } +} + /// Whether `who` is allowed to **write** `res` directly (vs. having to propose). /// -/// Pure policy, shared by the port's documented contract and the adapter: only the +/// Pure policy, shared by the port's documented contract and the adapter: only an /// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone -/// may write the per-agent context and memory. The orchestrator is modelled as -/// [`ConversationParty::User`] — IdeA's single logical operator identity (the -/// human-driven orchestrator), never a project [`AgentId`]. +/// may write the per-agent context and memory. Orchestrator identity is resolved +/// against the project's [`OrchestratorDesignation`] (the human is always one; a +/// project agent only when explicitly designated). #[must_use] -pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool { +pub fn may_write_directly( + who: ConversationParty, + res: &GuardedResource, + d: &OrchestratorDesignation, +) -> bool { if res.is_project_context() { - is_orchestrator(who) + is_orchestrator(who, d) } else { true } } -/// Whether `who` is the orchestrator identity for the single-writer rule. +/// Whether `who` is an orchestrator identity for the single-writer rule, given the +/// project's [`OrchestratorDesignation`] `d`. /// -/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a -/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must -/// propose changes to the global context. +/// The human-driven operator ([`ConversationParty::User`]) is **always** an +/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator +/// **only** when it is the designated one; otherwise it must propose changes to the +/// global context. #[must_use] -pub const fn is_orchestrator(who: ConversationParty) -> bool { - matches!(who, ConversationParty::User) +pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool { + match who { + ConversationParty::User => true, + ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id), + } } #[cfg(test)] @@ -195,13 +236,41 @@ mod tests { #[test] fn project_context_is_single_writer_to_orchestrator_only() { + // Single-writer rule preserved with the default (no designated agent): + // the human writes, every agent is refused. + let default = OrchestratorDesignation::none(); assert!(may_write_directly( ConversationParty::User, - &GuardedResource::ProjectContext + &GuardedResource::ProjectContext, + &default, )); assert!(!may_write_directly( agent_party(1), - &GuardedResource::ProjectContext + &GuardedResource::ProjectContext, + &default, + )); + } + + #[test] + fn designated_agent_may_write_project_context() { + let id = AgentId::from_uuid(uuid::Uuid::from_u128(1)); + let designation = OrchestratorDesignation::of(id); + // The designated agent now writes the global context directly… + assert!(may_write_directly( + ConversationParty::agent(id), + &GuardedResource::ProjectContext, + &designation, + )); + // …while another agent stays refused, and the human stays allowed. + assert!(!may_write_directly( + agent_party(2), + &GuardedResource::ProjectContext, + &designation, + )); + assert!(may_write_directly( + ConversationParty::User, + &GuardedResource::ProjectContext, + &designation, )); } @@ -209,16 +278,34 @@ mod tests { fn agent_context_and_memory_are_writable_by_anyone() { let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap()); let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9))); + let default = OrchestratorDesignation::none(); for who in [ConversationParty::User, agent_party(1)] { - assert!(may_write_directly(who, &mem)); - assert!(may_write_directly(who, &ctx)); + assert!(may_write_directly(who, &mem, &default)); + assert!(may_write_directly(who, &ctx, &default)); } } #[test] - fn is_orchestrator_only_for_user() { - assert!(is_orchestrator(ConversationParty::User)); - assert!(!is_orchestrator(agent_party(1))); + fn is_orchestrator_for_user_and_designated_agent() { + let id = AgentId::from_uuid(uuid::Uuid::from_u128(1)); + // Human is always an orchestrator, designation or not. + assert!(is_orchestrator( + ConversationParty::User, + &OrchestratorDesignation::none() + )); + // An agent is an orchestrator only when it is the designated one. + assert!(!is_orchestrator( + ConversationParty::agent(id), + &OrchestratorDesignation::none() + )); + assert!(is_orchestrator( + ConversationParty::agent(id), + &OrchestratorDesignation::of(id) + )); + assert!(!is_orchestrator( + agent_party(2), + &OrchestratorDesignation::of(id) + )); } #[test] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 8fca7d6..4cbc8db 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -49,9 +49,9 @@ pub mod ports; pub mod profile; pub mod project; pub mod readiness; +pub mod remote; pub mod sandbox; pub mod session_limit; -pub mod remote; pub mod skill; pub mod template; pub mod terminal; @@ -93,7 +93,7 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource}; pub use readiness::{ReadinessPolicy, ReadinessSignal}; -pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit}; +pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; pub use conversation_log::{ ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer, @@ -101,8 +101,8 @@ pub use conversation_log::{ }; pub use fileguard::{ - is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, - WriteLease, + is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, + OrchestratorDesignation, ReadLease, WriteLease, }; pub use markdown::MarkdownDoc; @@ -126,7 +126,7 @@ pub use permission::{ render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability, CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError, PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture, - ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION, + ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION, }; pub use sandbox::{ @@ -145,6 +145,6 @@ pub use ports::{ FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle, - PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec, - StoreError, TemplateStore, + PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, + SpawnSpec, StoreError, TemplateStore, }; diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index 38e3d46..89fbb23 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -894,8 +894,11 @@ pub trait PermissionProjector: Send + Sync { /// /// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]): /// we keep the CLI's native prompting (the product invariant of [`resolve`]). - fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) - -> PermissionProjection; + fn project( + &self, + eff: Option<&EffectivePermissions>, + ctx: &ProjectionContext, + ) -> PermissionProjection; /// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this /// projector owns, so the swap path can clean them up when an agent moves @@ -1448,7 +1451,10 @@ mod tests { let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary"); // Files: OS-enforced / Landlock when supported. - assert!(md.contains("OS-enforced"), "files block must say OS-enforced"); + assert!( + md.contains("OS-enforced"), + "files block must say OS-enforced" + ); assert!(md.contains("Landlock"), "files block must name Landlock"); // Commands: advisory, NOT OS-locked, and the why (ExecuteBash). assert!(md.contains("advisory"), "commands must be called advisory"); @@ -1462,7 +1468,10 @@ mod tests { ); // The actual rules surface in their respective sections. assert!(md.contains("`src/**`"), "the file scope is shown"); - assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown"); + assert!( + md.contains("`rm *` (prefix)"), + "the command matcher is shown" + ); // Resolved posture is surfaced. assert!(md.contains("**Default posture:** Ask")); } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 6a8fd47..5cd37ae 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -112,6 +112,8 @@ pub struct PreparedContext { pub content: MarkdownDoc, /// Relative path of the `.md` inside the project. pub relative_path: String, + /// Absolute root of the project this context belongs to. + pub project_root: String, } /// Enriched, **best-effort** details about a conversation, specific to a CLI's diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index bd597b2..aa0b8ef 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -422,6 +422,12 @@ pub struct McpServerWiring { } impl McpServerWiring { + /// Codex's MCP client defaults tool calls to a short interactive timeout (120s on + /// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous + /// that can legitimately span a full delegated dev/test turn, so the generated + /// server config must widen the per-tool timeout. + pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60; + /// Construit le wiring depuis ses parties. #[must_use] pub const fn new(command: String, args: Vec, transport: McpTransport) -> Self { @@ -475,9 +481,9 @@ impl McpServerWiring { } /// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex : - /// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en - /// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes - /// restent valides). + /// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique + /// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML + /// (équivalent du `json_string` : espaces/backslash/quotes restent valides). #[must_use] pub fn to_config_toml(&self) -> String { let command = toml_string(&self.command); @@ -488,8 +494,9 @@ impl McpServerWiring { .collect::>() .join(", "); let transport = self.transport_label(); + let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC; format!( - "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n" + "[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n" ) } } @@ -910,11 +917,16 @@ impl AgentProfile { /// ([`crate`] côté application) et la matérialisation ne puissent pas diverger. #[must_use] pub fn materializes_idea_bridge(&self) -> bool { - match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) { + match ( + self.structured_adapter, + self.mcp.as_ref().map(|c| &c.config), + ) { (Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => { target == ".mcp.json" } - (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true, + (Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => { + true + } _ => false, } } @@ -1262,7 +1274,10 @@ mod mcp_tests { !json.contains("stallAfterMs"), "an unset stall threshold must be omitted; got: {json}" ); - assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}"); + assert!( + json.contains("turnTimeoutMs"), + "set threshold present: {json}" + ); } #[test] @@ -1351,8 +1366,7 @@ mod mcp_tests { assert!(codex.materializes_idea_bridge()); // Codex WITHOUT any MCP capability ⇒ no bridge. - let codex_no_mcp = - profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex); + let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex); assert!(!codex_no_mcp.materializes_idea_bridge()); // Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge. @@ -1448,6 +1462,16 @@ mod mcp_tests { toml.contains("transport = \"stdio\""), "transport expected; got: {toml}" ); + // IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting + // `.mcp.json` path. + assert!( + toml.contains("default_tools_approval_mode = \"approve\""), + "IdeA MCP tools should not prompt for approval; got: {toml}" + ); + assert!( + toml.contains("tool_timeout_sec = 86400"), + "IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}" + ); } // -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------ @@ -1519,8 +1543,14 @@ mod mcp_tests { let json = serde_json::to_string(&profile).expect("serialise"); assert!(json.contains("rateLimitPattern"), "key present: {json}"); // camelCase respecté sur les champs de RateLimitPattern. - assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}"); - assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}"); + assert!( + json.contains("resetCapture"), + "camelCase field resetCapture: {json}" + ); + assert!( + json.contains("timeFormat"), + "camelCase field timeFormat: {json}" + ); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); assert_eq!(profile, back); @@ -1531,7 +1561,10 @@ mod mcp_tests { // reset_capture / time_format à None ⇒ leurs clés sont omises. let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern"); let json = serde_json::to_string(&pattern).expect("serialise"); - assert!(json.contains("\"pattern\""), "pattern field present: {json}"); + assert!( + json.contains("\"pattern\""), + "pattern field present: {json}" + ); assert!( !json.contains("resetCapture"), "an unset resetCapture must be omitted; got: {json}" diff --git a/crates/domain/src/readiness.rs b/crates/domain/src/readiness.rs index 459d993..44e64a9 100644 --- a/crates/domain/src/readiness.rs +++ b/crates/domain/src/readiness.rs @@ -89,11 +89,9 @@ impl ReadinessPolicy { pub const fn classify(event: &ReplyEvent) -> Option { match event { ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded), - ReplyEvent::RateLimited { resets_at_ms } => { - Some(ReadinessSignal::RateLimited { - resets_at_ms: *resets_at_ms, - }) - } + ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited { + resets_at_ms: *resets_at_ms, + }), ReplyEvent::TextDelta { .. } | ReplyEvent::ToolActivity { .. } | ReplyEvent::Heartbeat => None, @@ -123,7 +121,9 @@ mod tests { None ); assert_eq!( - ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }), + ReadinessPolicy::classify(&ReplyEvent::ToolActivity { + label: "lit".into() + }), None ); assert_eq!( diff --git a/crates/domain/src/sandbox.rs b/crates/domain/src/sandbox.rs index 9af337e..13eaabc 100644 --- a/crates/domain/src/sandbox.rs +++ b/crates/domain/src/sandbox.rs @@ -392,7 +392,7 @@ fn join_root(base: &str, rel: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve}; + use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet}; // ---- helpers --------------------------------------------------------- @@ -668,7 +668,10 @@ mod tests { PathAccess::RO, "RW class fenced out, RO class preserved on the same root" ); - assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped"); + assert!( + !g.access.contains(PathAccess::RW), + "the RW grant was dropped" + ); } #[test] diff --git a/crates/domain/src/session_limit.rs b/crates/domain/src/session_limit.rs index 0caaf17..b281962 100644 --- a/crates/domain/src/session_limit.rs +++ b/crates/domain/src/session_limit.rs @@ -163,7 +163,10 @@ mod tests { let plan = plan_resume(NOW, &limit(Some(past)), None); match plan { ResumePlan::Scheduled { fire_at_ms, .. } => { - assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)"); + assert_eq!( + fire_at_ms, NOW, + "un reset déjà passé ⇒ reprise immédiate (now)" + ); } other => panic!("attendu Scheduled, obtenu {other:?}"), } diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index ea136b7..28b21b1 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -392,6 +392,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() { let ctx = PreparedContext { content: MarkdownDoc::new("# ctx"), relative_path: "CLAUDE.md".to_owned(), + project_root: "/srv/project".to_owned(), }; let cwd = ProjectPath::new("/srv/run").unwrap(); let session = factory diff --git a/crates/infrastructure/src/fileguard/mod.rs b/crates/infrastructure/src/fileguard/mod.rs index a5ac76b..3ea9770 100644 --- a/crates/infrastructure/src/fileguard/mod.rs +++ b/crates/infrastructure/src/fileguard/mod.rs @@ -5,10 +5,17 @@ //! concurrent readers **or** one exclusive writer per resource; different resources //! are independent (their locks are distinct). //! -//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced -//! **before** taking any lock: a `who` that is not the orchestrator -//! ([`domain::may_write_directly`] returning `false`) is rejected with -//! [`GuardError::Forbidden`] — it must *propose* instead. +//! ## Pure lock — no authorization here (cadrage « orchestrateur du projet », Alt. A) +//! +//! This adapter is a **pure serialisation primitive**: `acquire_write` never returns +//! [`GuardError::Forbidden`]. The single-writer *authorization* of the global +//! [`GuardedResource::ProjectContext`] (« only the orchestrator may write it directly, +//! everyone else proposes ») is **not** a concurrency concern and no longer lives in +//! the guard. It is decided one layer up, in the `ProposeContext` use case, which +//! consults the project's `OrchestratorDesignation` (now that the orchestrator can be +//! a *designated agent*, not just the human, the guard — which only sees a +//! [`ConversationParty`] — could not make that call coherently anyway). The guard's +//! sole job is to make readers/writers of the same resource take turns. //! //! ## Cooperative scope (cadrage §9.5) //! @@ -32,9 +39,7 @@ use async_trait::async_trait; use tokio::sync::RwLock; use domain::conversation::ConversationParty; -use domain::fileguard::{ - may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease, -}; +use domain::fileguard::{FileGuard, GuardError, GuardedResource, ReadLease, WriteLease}; /// The [`FileGuard`] adapter: one reader/writer lock per guarded resource. /// @@ -85,14 +90,12 @@ impl FileGuard for RwFileGuard { async fn acquire_write( &self, - who: ConversationParty, + _who: ConversationParty, res: GuardedResource, ) -> Result { - // Single-writer rule for the global project context: refuse *before* taking - // any lock so a forbidden writer never blocks readers. - if !may_write_directly(who, &res) { - return Err(GuardError::Forbidden); - } + // Pure lock (Alt. A): no authorization here — `ProposeContext` decides who may + // write the global context. Serialise behind any in-flight readers/writer of + // the same resource, then hand back the exclusive RAII lease. let lock = self.lock_for(&res); let guard = lock.write_owned().await; Ok(WriteLease::new(Box::new(guard))) @@ -170,22 +173,20 @@ mod tests { } #[tokio::test] - async fn agent_writing_project_context_is_forbidden() { + async fn pure_lock_grants_project_context_write_to_any_party() { + // Alt. A: the guard is a pure lock — it no longer enforces the single-writer + // *authorization* (that moved into `ProposeContext`). Both the human and an + // arbitrary agent obtain a write lease on the project context; `Forbidden` is + // never produced at this layer. let guard = RwFileGuard::new(); - let err = guard + assert!(guard + .acquire_write(ConversationParty::User, GuardedResource::ProjectContext) + .await + .is_ok()); + assert!(guard .acquire_write(agent_party(1), GuardedResource::ProjectContext) .await - .unwrap_err(); - assert_eq!(err, GuardError::Forbidden); - } - - #[tokio::test] - async fn orchestrator_may_write_project_context() { - let guard = RwFileGuard::new(); - let lease = guard - .acquire_write(ConversationParty::User, GuardedResource::ProjectContext) - .await; - assert!(lease.is_ok()); + .is_ok()); } #[tokio::test] diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 5198ebc..82fb423 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -94,6 +94,10 @@ type HeadlessSink = /// la soumission du collage pour esquiver la paste-detection des CLI. Utilisé seulement /// quand le profil de la cible ne fournit pas de `submit_delay_ms`. const DEFAULT_SUBMIT_DELAY_MS: u32 = 60; +/// Taille maximale d'un fragment de consigne écrit dans le PTY en mode headless. +const DELEGATION_WRITE_CHUNK_BYTES: usize = 512; +/// Pause entre deux fragments de consigne en mode headless. +const DELEGATION_WRITE_CHUNK_DELAY_MS: u64 = 8; /// État de vivacité d'un agent maintenu par le [`BusyTracker`] (lot 2). /// @@ -486,8 +490,10 @@ impl MediatedInbox { // watcher prompt-ready, tâche tokio du serveur MCP, ou le fil de l'enqueue). // Texte d'abord, puis la séquence de soumission après le délai — comme le // write-portal frontend — pour esquiver la détection de coller des CLI. + // La consigne est fragmentée : les TUI (Codex surtout) peuvent traiter un + // gros write unique comme un paste et garder/perdre la fin au démarrage. std::thread::spawn(move || { - let _ = pty.write(&handle, text.as_bytes()); + let _ = write_delegation_chunks(pty.as_ref(), &handle, &text); if delay > 0 { std::thread::sleep(std::time::Duration::from_millis(delay)); } @@ -662,6 +668,30 @@ fn delegation_preamble(requester: &str, ticket: TicketId, task: &str) -> String format!("[IdeA · tâche de {requester} · ticket {ticket}]\n{task}") } +fn write_delegation_chunks( + pty: &dyn PtyPort, + handle: &PtyHandle, + text: &str, +) -> Result<(), domain::ports::PtyError> { + let mut start = 0; + let mut current_len = 0; + + for (idx, ch) in text.char_indices() { + let len = ch.len_utf8(); + if current_len > 0 && current_len + len > DELEGATION_WRITE_CHUNK_BYTES { + pty.write(handle, text[start..idx].as_bytes())?; + std::thread::sleep(std::time::Duration::from_millis( + DELEGATION_WRITE_CHUNK_DELAY_MS, + )); + start = idx; + current_len = 0; + } + current_len += len; + } + + pty.write(handle, text[start..].as_bytes()) +} + impl InputMediator for MediatedInbox { fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; @@ -1115,6 +1145,43 @@ mod tests { ); } + #[test] + fn headless_agent_chunks_long_delegation_before_submit() { + let pty = Arc::new(FakePty::new()); + let bus = Arc::new(RecordingBus::default()); + let inbox = MediatedInbox::with_pty( + Arc::new(InMemoryMailbox::new()), + Arc::new(FixedClock(1)), + Arc::clone(&pty) as Arc, + ) + .with_events(Arc::clone(&bus) as Arc); + let a = agent(1); + let task = format!("début {} fin", "x".repeat(1200)); + inbox.bind_handle_with_prompt(a, handle(1), None, SubmitConfig::default()); + + inbox.enqueue(a, ticket(10, &task)); + + assert!( + wait_until(|| pty.writes.lock().unwrap().len() >= 4), + "long headless delivery writes multiple chunks followed by submit" + ); + let writes = pty.writes.lock().unwrap().clone(); + assert_eq!(writes.last().map(Vec::as_slice), Some(b"\r".as_slice())); + let delivered = writes[..writes.len() - 1] + .iter() + .flat_map(|chunk| chunk.iter().copied()) + .collect::>(); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains(&task), + "chunked writes reconstruct the full task" + ); + assert!( + bus.delegation_ready().is_empty(), + "headless delivery does not also publish DelegationReady" + ); + } + #[tokio::test] async fn enqueue_returns_pending_reply_resolved_via_mailbox() { let inbox = inbox_at(5); diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index b05eb4a..96a0a7b 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -57,11 +57,11 @@ pub use orchestrator::{ pub use permission::{ClaudePermissionProjector, CodexPermissionProjector}; pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; +pub use ratelimit::RateLimitParser; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; #[cfg(target_os = "linux")] pub use sandbox::LandlockSandbox; -pub use ratelimit::RateLimitParser; pub use sandbox::{default_enforcer, NoopSandbox}; pub use scheduler::TokioScheduler; pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory}; diff --git a/crates/infrastructure/src/permission/claude.rs b/crates/infrastructure/src/permission/claude.rs index 1bc2487..920c68b 100644 --- a/crates/infrastructure/src/permission/claude.rs +++ b/crates/infrastructure/src/permission/claude.rs @@ -292,8 +292,14 @@ mod tests { let deny = str_array(&json["permissions"]["deny"]); // A Write capability fans out to both Edit(..) and Write(..) entries. - assert!(deny.contains(&"Edit(.ideai/**)".to_owned()), "deny={deny:?}"); - assert!(deny.contains(&"Write(.ideai/**)".to_owned()), "deny={deny:?}"); + assert!( + deny.contains(&"Edit(.ideai/**)".to_owned()), + "deny={deny:?}" + ); + assert!( + deny.contains(&"Write(.ideai/**)".to_owned()), + "deny={deny:?}" + ); let allow = str_array(&json["permissions"]["allow"]); assert!( @@ -330,7 +336,10 @@ mod tests { "Bash(shutdown*)", "Bash(reboot*)", ] { - assert!(deny.contains(&guard.to_owned()), "missing guardrail {guard}; deny={deny:?}"); + assert!( + deny.contains(&guard.to_owned()), + "missing guardrail {guard}; deny={deny:?}" + ); } } diff --git a/crates/infrastructure/src/permission/codex.rs b/crates/infrastructure/src/permission/codex.rs index 8e994a1..acf7f8a 100644 --- a/crates/infrastructure/src/permission/codex.rs +++ b/crates/infrastructure/src/permission/codex.rs @@ -2,9 +2,11 @@ //! //! Produces the **permission-relevant** part of Codex's `config.toml` //! (`sandbox_mode` / `approval_policy`) plus the matching launch args -//! (`--sandbox` / `--ask-for-approval`). The posture→mode derivation is extracted -//! verbatim from the former `codex_sandbox_mode` / `codex_approval_policy` / -//! `apply_codex_cli_permission_args` in `lifecycle.rs`. +//! (`--sandbox` / `--ask-for-approval`) and, for workspace-write postures, the +//! project root as an additional writable directory (`--add-dir`). The +//! posture→mode derivation is extracted verbatim from the former +//! `codex_sandbox_mode` / `codex_approval_policy` / `apply_codex_cli_permission_args` +//! in `lifecycle.rs`. //! //! Unlike Claude's seed, Codex's `config.toml` is **co-owned** (it also carries the //! `mcp_servers.idea` table and the `projects.*` trust entries, which are MCP/trust @@ -44,7 +46,7 @@ impl PermissionProjector for CodexPermissionProjector { fn project( &self, eff: Option<&EffectivePermissions>, - _ctx: &ProjectionContext, + ctx: &ProjectionContext, ) -> PermissionProjection { // Product invariant: nothing posed ⇒ nothing projected. Codex keeps its // native sandbox/approval defaults (no args, no managed keys written). @@ -63,6 +65,17 @@ impl PermissionProjector for CodexPermissionProjector { toml_string(approval), ); + let mut args = vec![ + "--sandbox".to_owned(), + sandbox.to_owned(), + "--ask-for-approval".to_owned(), + approval.to_owned(), + ]; + if sandbox == "workspace-write" && !ctx.project_root.is_empty() { + args.push("--add-dir".to_owned()); + args.push(ctx.project_root.to_owned()); + } + PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: CONFIG_REL_PATH.to_owned(), @@ -70,12 +83,7 @@ impl PermissionProjector for CodexPermissionProjector { managed_keys: MANAGED_KEYS.iter().map(|k| (*k).to_owned()).collect(), contents, }], - args: vec![ - "--sandbox".to_owned(), - sandbox.to_owned(), - "--ask-for-approval".to_owned(), - approval.to_owned(), - ], + args, env: Vec::new(), } } @@ -135,7 +143,7 @@ mod tests { } // ---- (6) posture → sandbox_mode / approval_policy + (7) args↔contents - // coherence + MergeToml shape ------------------------------- + // coherence + add-dir + MergeToml shape --------------------- #[test] fn posture_maps_sandbox_and_approval_in_file_and_args() { @@ -176,7 +184,7 @@ mod tests { // -- (7) Args reflect the SAME values as the TOML, in CLI order. assert_eq!( - proj.args, + &proj.args[..4], vec![ "--sandbox".to_owned(), sandbox.to_owned(), @@ -185,6 +193,21 @@ mod tests { ], "posture {posture:?}: args must mirror the TOML values" ); + if sandbox == "workspace-write" { + assert!( + proj.args + .windows(2) + .any(|w| w == ["--add-dir".to_owned(), "/proj".to_owned()]), + "workspace-write posture must add the project root as writable: {:?}", + proj.args + ); + } else { + assert!( + !proj.args.contains(&"--add-dir".to_owned()), + "read-only posture must not add writable dirs: {:?}", + proj.args + ); + } } } } diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index fd7af8d..87c4146 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -520,8 +520,10 @@ mod sandbox_e2e_tests { #[tokio::test] async fn pty_spawn_enforces_sandbox_plan_end_to_end() { if !landlock_is_enforced() { - eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \ - Landlock not available/enforced on this kernel"); + eprintln!( + "skipping pty_spawn_enforces_sandbox_plan_end_to_end: \ + Landlock not available/enforced on this kernel" + ); return; } diff --git a/crates/infrastructure/src/ratelimit/mod.rs b/crates/infrastructure/src/ratelimit/mod.rs index efcdf96..c59a5ae 100644 --- a/crates/infrastructure/src/ratelimit/mod.rs +++ b/crates/infrastructure/src/ratelimit/mod.rs @@ -92,10 +92,7 @@ impl ResetTimeFormat { Self::EpochSeconds => c.parse::().ok().map(|s| s.saturating_mul(1000)), Self::EpochMillis => c.parse::().ok(), Self::Iso8601 => timeparse::parse_rfc3339_to_ms(c), - Self::RelativeSeconds => c - .parse::() - .ok() - .map(|d| now_ms + (d * 1000.0) as i64), + Self::RelativeSeconds => c.parse::().ok().map(|d| now_ms + (d * 1000.0) as i64), Self::RelativeMillis => c.parse::().ok().map(|d| now_ms + d), Self::WallClock => { let (h, m, s) = timeparse::parse_wall_clock(c)?; @@ -153,7 +150,11 @@ impl RateLimitParser { .or_else(|| name.parse::().ok().and_then(|i| caps.get(i)))?; self.time_format.resolve(raw.as_str(), now_ms) }); - Some(SessionLimit::new(resets_at_ms, now_ms, RateLimitSource::Pattern)) + Some(SessionLimit::new( + resets_at_ms, + now_ms, + RateLimitSource::Pattern, + )) } } @@ -204,7 +205,9 @@ mod tests { #[test] fn detect_match_without_reset_capture_has_no_time() { let parser = RateLimitParser::new(&pattern("rate limit reached", None, None)).unwrap(); - let limit = parser.detect("oops: rate limit reached!", NOW).expect("détecté"); + let limit = parser + .detect("oops: rate limit reached!", NOW) + .expect("détecté"); assert_eq!(limit.resets_at_ms, None); assert_eq!(limit.detected_at_ms, NOW); assert_eq!(limit.source, RateLimitSource::Pattern); @@ -340,9 +343,13 @@ mod tests { Some("epoch_s"), )) .unwrap(); - let a = parser.detect("resets at 1700000000", NOW).expect("1er détecté"); + let a = parser + .detect("resets at 1700000000", NOW) + .expect("1er détecté"); assert!(parser.detect("rien ici", NOW).is_none()); - let b = parser.detect("resets at 1700000000", NOW).expect("2e détecté"); + let b = parser + .detect("resets at 1700000000", NOW) + .expect("2e détecté"); assert_eq!(a.resets_at_ms, b.resets_at_ms); assert_eq!(a.resets_at_ms, Some(1_700_000_000_000)); } @@ -368,7 +375,10 @@ mod tests { let profile = base_profile() .with_structured_adapter(StructuredAdapter::Claude) .with_rate_limit_pattern(pattern("rate limit", None, None)); - assert!(!applies(&profile), "un agent structuré détecte par le niveau 1"); + assert!( + !applies(&profile), + "un agent structuré détecte par le niveau 1" + ); } #[test] diff --git a/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs index ebad8aa..4f37056 100644 --- a/crates/infrastructure/src/sandbox/landlock.rs +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -324,8 +324,7 @@ mod tests { .enforce(&plan) .expect("an Allow fallback never fails closed"); let read = std::fs::read(outside_t.join("pre.txt")); - let write = - std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind()); + let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind()); (status, read, write) }) .join() diff --git a/crates/infrastructure/src/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs index 5e60bb9..b78a46d 100644 --- a/crates/infrastructure/src/scheduler/mod.rs +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -24,7 +24,7 @@ use tokio::sync::mpsc::UnboundedSender; use tokio::task::JoinHandle; use domain::ids::ScheduleId; -use domain::ports::{Clock, Scheduler, ScheduledTask}; +use domain::ports::{Clock, ScheduledTask, Scheduler}; /// Adapter tokio du port [`Scheduler`] (§21.4). Construire avec [`TokioScheduler::new`]. /// @@ -111,7 +111,7 @@ mod tests { use uuid::Uuid; use domain::ids::{AgentId, NodeId, ScheduleId}; - use domain::ports::{Clock, Scheduler, ScheduledTask}; + use domain::ports::{Clock, ScheduledTask, Scheduler}; use super::TokioScheduler; use crate::SystemClock; @@ -126,7 +126,11 @@ mod tests { /// Construit un scheduler + le bout récepteur du canal de remise + l'horloge réelle /// (partagée, pour calculer des échéances cohérentes avec celle qu'`arm` lira). - fn make() -> (TokioScheduler, UnboundedReceiver, Arc) { + fn make() -> ( + TokioScheduler, + UnboundedReceiver, + Arc, + ) { let (tx, rx) = unbounded_channel(); let clock: Arc = Arc::new(SystemClock::new()); (TokioScheduler::new(tx, clock.clone()), rx, clock) diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 703bbe7..45a351c 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -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, /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. conversation_id: Mutex>, /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque @@ -143,6 +145,7 @@ impl CodexExecSession { command: impl Into, cwd: impl Into, seed_conversation_id: Option, + writable_roots: Vec, sandbox: Option, sandbox_enforcer: Option>, ) -> 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 `. + /// --sandbox workspace-write --add-dir `. /// - Reprise (id connu) : `codex exec resume --json - /// --skip-git-repo-check --sandbox workspace-write `. + /// --skip-git-repo-check --sandbox workspace-write --add-dir + /// `. /// /// **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(), diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 5eedb3e..77e57fc 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -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 = 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) } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 09ef248..91ddd28 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -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 = 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, ] + // [exec, --json, --skip-git-repo-check, --sandbox, workspace-write, + // --add-dir, , ] // (resume 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, ]`. Pas de sous-commande `resume`, - /// pas de `--ask-for-approval`. + /// --sandbox, workspace-write, --add-dir, , ]`. + /// 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, , --json, - /// --skip-git-repo-check, --sandbox, workspace-write, ]`. Toujours - /// pas de `--ask-for-approval`. + /// --skip-git-repo-check, --sandbox, workspace-write, --add-dir, , + /// ]`. 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 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 = session.send("x").await.expect("send ok").collect(); assert_eq!( events, diff --git a/crates/infrastructure/src/session/process.rs b/crates/infrastructure/src/session/process.rs index 0b82d37..9c5a6d9 100644 --- a/crates/infrastructure/src/session/process.rs +++ b/crates/infrastructure/src/session/process.rs @@ -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); }); diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs index 59cdc41..c5b5f89 100644 --- a/crates/infrastructure/src/session/sandbox_e2e.rs +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -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!( diff --git a/crates/infrastructure/src/store/context.rs b/crates/infrastructure/src/store/context.rs index 10210b5..114d7dd 100644 --- a/crates/infrastructure/src/store/context.rs +++ b/crates/infrastructure/src/store/context.rs @@ -152,6 +152,7 @@ impl AgentContextStore for IdeaiContextStore { Err(FsError::NotFound(_)) => Ok(AgentManifest { version: MANIFEST_VERSION, entries: Vec::new(), + orchestrator: None, }), Err(e) => Err(StoreError::Io(e.to_string())), } diff --git a/crates/infrastructure/src/timeparse.rs b/crates/infrastructure/src/timeparse.rs index a1eae6e..cd98855 100644 --- a/crates/infrastructure/src/timeparse.rs +++ b/crates/infrastructure/src/timeparse.rs @@ -186,7 +186,11 @@ fn split_seconds_frac(s: &str) -> Option<(i64, i64)> { return Some((s.parse::().ok()?, 0)); }; let sec = sec.parse::().ok()?; - let mut d3: String = frac.chars().take_while(char::is_ascii_digit).take(3).collect(); + let mut d3: String = frac + .chars() + .take_while(char::is_ascii_digit) + .take(3) + .collect(); while d3.len() < 3 { d3.push('0'); } @@ -257,7 +261,10 @@ mod tests { fn wall_clock_to_ms_same_day_when_future() { let now_10h = DAY_START + 10 * 3_600_000; // 15:00 est dans le futur ⇒ même jour. - assert_eq!(wall_clock_to_ms(now_10h, 15, 0, 0), DAY_START + 15 * 3_600_000); + assert_eq!( + wall_clock_to_ms(now_10h, 15, 0, 0), + DAY_START + 15 * 3_600_000 + ); } #[test] @@ -282,7 +289,8 @@ mod tests { #[test] fn parse_absolute_ms_epoch_seconds_and_millis() { assert_eq!(parse_absolute_ms("1700000000"), Some(1_700_000_000_000)); // s ⇒ ×1000 - assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); // ms tel quel + assert_eq!(parse_absolute_ms("1700000000000"), Some(1_700_000_000_000)); + // ms tel quel } #[test] diff --git a/crates/infrastructure/tests/agent_runtime.rs b/crates/infrastructure/tests/agent_runtime.rs index 1397e16..431413c 100644 --- a/crates/infrastructure/tests/agent_runtime.rs +++ b/crates/infrastructure/tests/agent_runtime.rs @@ -45,6 +45,7 @@ fn ctx() -> PreparedContext { PreparedContext { content: MarkdownDoc::new("# hi"), relative_path: ".ideai/agent.md".to_owned(), + project_root: "/repo".to_owned(), } } diff --git a/crates/infrastructure/tests/context_store.rs b/crates/infrastructure/tests/context_store.rs index 9c2061d..6a7d1cf 100644 --- a/crates/infrastructure/tests/context_store.rs +++ b/crates/infrastructure/tests/context_store.rs @@ -151,3 +151,63 @@ async fn manifest_file_is_camelcase_json_under_ideai() { ); assert!(entry.get("md_path").is_none(), "no snake_case leak"); } + +#[tokio::test] +async fn legacy_manifest_without_orchestrator_loads_as_default() { + // A pre-feature `agents.json` has no `orchestrator` key. It must deserialise with + // `orchestrator == None` (serde default), so `effective_orchestrator()` falls back + // to the oldest entry (free backward compatibility). + let tmp = TempDir::new(); + let p = project(&tmp.root()); + let fs = LocalFileSystem::new(); + fs.create_dir_all(&tmp.child(".ideai")).await.unwrap(); + let legacy = r#"{ + "version": 1, + "agents": [ + { "agentId": "00000000-0000-0000-0000-000000000001", "name": "Oldest", + "mdPath": "agents/oldest.md", "profileId": "00000000-0000-0000-0000-0000000000aa", + "synchronized": false }, + { "agentId": "00000000-0000-0000-0000-000000000002", "name": "Newer", + "mdPath": "agents/newer.md", "profileId": "00000000-0000-0000-0000-0000000000aa", + "synchronized": false } + ] + }"#; + fs.write(&tmp.child(".ideai/agents.json"), legacy.as_bytes()) + .await + .unwrap(); + + let manifest = store().load_manifest(&p).await.unwrap(); + assert_eq!(manifest.orchestrator, None); + assert_eq!(manifest.effective_orchestrator(), Some(aid(1))); +} + +#[tokio::test] +async fn manifest_with_orchestrator_roundtrips_and_emits_camelcase_key() { + let tmp = TempDir::new(); + let store = store(); + let p = project(&tmp.root()); + + let a = agent(aid(1), "Backend", "agents/backend.md", pid(9)); + let b = agent(aid(2), "Frontend", "agents/frontend.md", pid(9)); + let mut manifest = AgentManifest::new( + 1, + vec![ManifestEntry::from_agent(&a), ManifestEntry::from_agent(&b)], + ) + .unwrap(); + manifest.designate(aid(2)).unwrap(); + store.save_manifest(&p, &manifest).await.unwrap(); + + // Round-trips byte-for-byte through the store… + let back = store.load_manifest(&p).await.unwrap(); + assert_eq!(back, manifest); + assert_eq!(back.effective_orchestrator(), Some(aid(2))); + + // …and the on-disk JSON carries the camelCase `orchestrator` key. + let fs = LocalFileSystem::new(); + let bytes = fs.read(&tmp.child(".ideai/agents.json")).await.unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + json.get("orchestrator").and_then(|v| v.as_str()), + Some("00000000-0000-0000-0000-000000000002") + ); +} diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index bbff51f..708e8de 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -78,6 +78,7 @@ impl FakeContexts { manifest: AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }, contents: HashMap::new(), }))) @@ -1227,21 +1228,18 @@ async fn ask_agent_rendezvous_times_out_with_a_jsonrpc_error() { agent_id, SessionId::from_uuid(Uuid::from_u128(910)), ); - let server = server(service) - .with_ask_rendezvous_timeout(std::time::Duration::from_millis(50)); + let server = server(service).with_ask_rendezvous_timeout(std::time::Duration::from_millis(50)); let raw = tools_call( 1, "idea_ask_agent", json!({ "target": "architect", "task": "never answered" }), ); - let response = tokio::time::timeout( - std::time::Duration::from_secs(10), - server.handle_raw(&raw), - ) - .await - .expect("must not hang past the injected timeout") - .expect("reply owed"); + let response = + tokio::time::timeout(std::time::Duration::from_secs(10), server.handle_raw(&raw)) + .await + .expect("must not hang past the injected timeout") + .expect("reply owed"); let error = response.error.expect("a JSON-RPC error on timeout"); assert_eq!(error.code, error_codes::INTERNAL_ERROR, "got {error:?}"); diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 5806837..3100357 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -74,6 +74,7 @@ impl FakeContexts { manifest: AgentManifest { version: 1, entries: Vec::new(), + orchestrator: None, }, contents: HashMap::new(), })))