From 287681c198f71da146335927076e25d9ba3fe7fa Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 08:56:17 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(orchestrator):=20mod=C3=A8le=20de=20d?= =?UTF-8?q?=C3=A9signation=20d'orchestrateur=20+=20sink=20de=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduit le modèle AgentManifest { version, entries, orchestrator } et la garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) : seul l'orchestrateur désigné peut écrire directement, les autres passent par le rendez-vous médié. Câble la désignation à travers domain → application → infrastructure → app-tauri (context_guard, service, lifecycle, ports). Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans dépendance, qui miroite les traces du rendez-vous inter-agents de l'orchestrateur vers un fichier de log persistant (utile au lancement via AppImage où stderr est jeté), avec la même discipline « zéro dépendance, ne casse jamais le rendez-vous ». Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/commands.rs | 12 +- crates/app-tauri/src/events.rs | 17 ++ crates/app-tauri/src/lib.rs | 6 + crates/app-tauri/src/mcp_bridge.rs | 10 +- crates/app-tauri/src/state.rs | 277 ++++++++++++++---- .../app-tauri/tests/session_limit_wiring.rs | 17 +- crates/application/src/agent/catalogue.rs | 6 + crates/application/src/agent/lifecycle.rs | 9 +- crates/application/src/agent/mod.rs | 4 +- crates/application/src/agent/session_limit.rs | 27 +- crates/application/src/agent/structured.rs | 25 +- crates/application/src/diag.rs | 74 +++++ crates/application/src/lib.rs | 21 +- .../src/orchestrator/context_guard.rs | 50 ++-- .../application/src/orchestrator/service.rs | 141 +++++++-- crates/application/tests/agent_inspect.rs | 2 + crates/application/tests/agent_lifecycle.rs | 198 +++++++++---- .../application/tests/change_agent_profile.rs | 37 ++- .../tests/drain_with_readiness_lot1.rs | 15 +- .../tests/list_resumable_agents.rs | 2 + .../application/tests/orchestrator_service.rs | 1 + crates/application/tests/profile_usecases.rs | 7 +- .../tests/session_limit_service.rs | 120 +++++--- crates/application/tests/skill_usecases.rs | 1 + .../application/tests/structured_launch_d3.rs | 2 + crates/application/tests/template_usecases.rs | 1 + crates/domain/src/agent.rs | 233 ++++++++++++++- crates/domain/src/events.rs | 37 +++ crates/domain/src/fileguard.rs | 125 ++++++-- crates/domain/src/lib.rs | 14 +- crates/domain/src/permission.rs | 17 +- crates/domain/src/ports.rs | 2 + crates/domain/src/profile.rs | 57 +++- crates/domain/src/readiness.rs | 12 +- crates/domain/src/sandbox.rs | 7 +- crates/domain/src/session_limit.rs | 5 +- crates/domain/tests/structured_session_d0.rs | 1 + crates/infrastructure/src/fileguard/mod.rs | 53 ++-- crates/infrastructure/src/input/mod.rs | 69 ++++- crates/infrastructure/src/lib.rs | 2 +- .../infrastructure/src/permission/claude.rs | 15 +- crates/infrastructure/src/permission/codex.rs | 47 ++- crates/infrastructure/src/pty/mod.rs | 6 +- crates/infrastructure/src/ratelimit/mod.rs | 28 +- crates/infrastructure/src/sandbox/landlock.rs | 3 +- crates/infrastructure/src/scheduler/mod.rs | 10 +- crates/infrastructure/src/session/codex.rs | 20 +- crates/infrastructure/src/session/factory.rs | 20 +- crates/infrastructure/src/session/mod.rs | 192 ++++++++++-- crates/infrastructure/src/session/process.rs | 3 +- .../infrastructure/src/session/sandbox_e2e.rs | 25 +- crates/infrastructure/src/store/context.rs | 1 + crates/infrastructure/src/timeparse.rs | 14 +- crates/infrastructure/tests/agent_runtime.rs | 1 + crates/infrastructure/tests/context_store.rs | 60 ++++ crates/infrastructure/tests/mcp_server.rs | 16 +- .../tests/orchestrator_watcher.rs | 1 + 57 files changed, 1758 insertions(+), 420 deletions(-) create mode 100644 crates/application/src/diag.rs 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(), }))) From e462136df249458438ba5c1ceec86dadbdfc78dc Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 08:56:26 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(terminals):=20durcit=20le=20portail=20?= =?UTF-8?q?d'=C3=A9criture=20de=20d=C3=A9l=C3=A9gation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiabilise la livraison des délégations inter-agents dans le terminal : écriture par chunks UTF-8 bornés (512 o, délai 8 ms) pour éviter les comportements de paste/drop des TUI sur agents froids, et réconciliation de l'attachement front (frontAttachedAgentRef / reconcileFrontAttachment) pour ne reporter « front attaché » qu'une fois les DelegationReady réellement consommés. Tests vitest associés. Co-Authored-By: Claude Opus 4.8 --- .../src/features/projects/ProjectsView.tsx | 24 ++-- .../terminals/useWritePortal.test.tsx | 39 ++++++ .../src/features/terminals/useWritePortal.ts | 112 ++++++++++++++++-- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 4fd30ef..4993699 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -28,11 +28,11 @@ * - getByRole("alert") — error display */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; -import { AgentsPanel, ResumeProjectPanel } from "@/features/agents"; +import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { MemoryPanel } from "@/features/memory"; @@ -79,6 +79,15 @@ export function ProjectsView() { const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; + // Reset the active layout whenever the active project changes. `activeLayout` + // is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new + // project's layouts). Without this reset, the stale id of the *previous* + // project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and + // loading it against the new project's store fails with "not found: layout X". + useEffect(() => { + setActiveLayout(null); + }, [active?.id]); + const activeLayoutKind = activeLayout?.kind ?? "terminal"; const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy; @@ -355,17 +364,6 @@ export function ProjectsView() { )} - - {/* ── Reopen resume panel (§15.2) ── - Mounted per active project (keyed by id) so it re-pulls the resumable - inventory on every open/switch; it renders nothing when empty. */} - {active && ( - - )} ); } diff --git a/frontend/src/features/terminals/useWritePortal.test.tsx b/frontend/src/features/terminals/useWritePortal.test.tsx index 7d4eecf..3cdbc20 100644 --- a/frontend/src/features/terminals/useWritePortal.test.tsx +++ b/frontend/src/features/terminals/useWritePortal.test.tsx @@ -120,6 +120,25 @@ describe("useWritePortal (§20)", () => { ]); }); + it("chunks long delegated text before submit and acks after the submit", async () => { + const { system, input, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + const text = `début ${"x".repeat(1200)} fin`; + + await act(async () => { + system.emit(PROFILELESS("t1", text)); + await vi.runAllTimersAsync(); + }); + + expect(writes.length).toBeGreaterThan(2); + expect(writes[writes.length - 1]).toBe("\r"); + expect(writes.slice(0, -1).join("")).toBe(text); + expect(input.delivered).toEqual([ + { projectId: "p1", agentId: "ag1", ticket: "t1" }, + ]); + }); + it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); @@ -255,6 +274,26 @@ describe("useWritePortal (§20)", () => { expect(input.delivered.length).toBe(1); }); + it("reports front attachment only after the delegation subscription is ready", async () => { + const { input, view } = setup(); + const { handle } = makeHandle(); + + act(() => view.result.current.portal.bindHandle(handle)); + expect(input.frontAttached).toEqual([]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(input.frontAttached).toEqual([{ agentId: "ag1", attached: true }]); + + act(() => view.result.current.portal.unbindHandle()); + expect(input.frontAttached).toEqual([ + { agentId: "ag1", attached: true }, + { agentId: "ag1", attached: false }, + ]); + }); + it("is inert for a plain (agent-less) cell", async () => { const { system, view } = setup(null); const { handle, writes } = makeHandle(); diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts index 616bf26..aeb049f 100644 --- a/frontend/src/features/terminals/useWritePortal.ts +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -44,10 +44,19 @@ import { useGateways } from "@/app/di"; /** Default submit sequence when the profile omits one (paste-detection esquive). */ const DEFAULT_SUBMIT_SEQUENCE = "\r"; -/** Default delay (ms) between text and submit-sequence writes. */ -const DEFAULT_SUBMIT_DELAY_MS = 60; +/** + * Default delay (ms) between text and submit-sequence writes. + * + * Keep this conservative: Codex's interactive TUI can otherwise keep a delegated + * prompt in the input editor until the user presses Enter manually. + */ +const DEFAULT_SUBMIT_DELAY_MS = 350; /** Anti-flash overlay floor (ms) from step (b). */ const OVERLAY_FLOOR_MS = 2000; +/** Max UTF-8 bytes per delegation write chunk. */ +const DELEGATION_WRITE_CHUNK_BYTES = 512; +/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */ +const DELEGATION_WRITE_CHUNK_DELAY_MS = 8; /** A pending delegation kept in the local FIFO. */ interface PendingDelegation { @@ -99,6 +108,8 @@ export function useWritePortal( const handleRef = useRef(null); const suspendedRef = useRef(false); // relay suspended while injecting const injectingRef = useRef(false); // a handshake is in flight + const subscribedAgentRef = useRef(null); + const frontAttachedAgentRef = useRef(null); // Stable refs to the gateways used inside the (non-reactive) handshake. const inputRef = useRef(input); @@ -110,6 +121,28 @@ export function useWritePortal( const projectIdRef = useRef(projectId); projectIdRef.current = projectId; + // Backend routing must only switch to the frontend path once this cell can + // actually consume DelegationReady events. If we report "front attached" + // immediately at bindHandle time, a delegation emitted before the async event + // subscription is installed is lost: the backend publishes an event nobody is + // listening to, and it also skips the headless PTY write fallback. + const reconcileFrontAttachment = useRef<() => void>(() => {}); + reconcileFrontAttachment.current = () => { + const desired = handleRef.current && subscribedAgentRef.current + ? subscribedAgentRef.current + : null; + const previous = frontAttachedAgentRef.current; + if (previous === desired) return; + + if (previous) { + void inputRef.current?.setFrontAttached(previous, false).catch(() => {}); + } + if (desired) { + void inputRef.current?.setFrontAttached(desired, true).catch(() => {}); + } + frontAttachedAgentRef.current = desired; + }; + // ── (3)+(4): the handshake, attempted whenever a boundary may have opened ── const tryInject = useRef<() => void>(() => {}); tryInject.current = () => { @@ -147,7 +180,10 @@ export function useWritePortal( // (d) write the text WITHOUT a trailing newline, then the submit // sequence after the profile's delay (esquive de la paste-detection). - await handle.write(encoder.encode(head.text)); + // Long delegated prompts are chunked: Codex's TUI can otherwise treat a + // large single write like a paste and occasionally keep/drop the tail on + // cold-started agents before Enter is received. + await writeDelegationText(handle, head.text); const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS; await sleep(delay); const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE; @@ -176,14 +212,19 @@ export function useWritePortal( // ── (2): subscribe to delegationReady for THIS agent and enqueue ─────────── useEffect(() => { - if (!system || !agentId) return; + if (!system || !agentId) { + subscribedAgentRef.current = null; + reconcileFrontAttachment.current(); + return; + } let unsubscribe: (() => void) | undefined; let cancelled = false; + const subscribedAgent = agentId; void system .onDomainEvent((event: DomainEvent) => { if (event.type !== "delegationReady") return; - if (event.agentId !== agentId) return; + if (event.agentId !== subscribedAgent) return; queueRef.current.push({ ticket: event.ticket, text: event.text, @@ -195,12 +236,25 @@ export function useWritePortal( }) .then((un) => { if (cancelled) un(); - else unsubscribe = un; + else { + unsubscribe = un; + subscribedAgentRef.current = subscribedAgent; + reconcileFrontAttachment.current(); + } }); return () => { cancelled = true; unsubscribe?.(); + if (subscribedAgentRef.current === subscribedAgent) { + subscribedAgentRef.current = null; + } + if (frontAttachedAgentRef.current === subscribedAgent) { + void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {}); + frontAttachedAgentRef.current = null; + } else { + reconcileFrontAttachment.current(); + } }; }, [system, agentId]); @@ -225,11 +279,10 @@ export function useWritePortal( }, bindHandle(handle: TerminalHandle) { handleRef.current = handle; - // Tell the backend a frontend cell is now mounted for this agent, so the - // mediator routes its turns through `delegationReady` (this portal writes) - // rather than writing the PTY itself (the headless path for cell-less agents). - const agent = agentIdRef.current; - if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {}); + // Tell the backend a frontend cell is mounted only after the event + // subscription is ready. Until then, the backend keeps the headless PTY + // write fallback, so a delegation cannot disappear during mount. + reconcileFrontAttachment.current(); // The PTY just became available — a queued delegation may be injectable. tryInject.current(); }, @@ -237,8 +290,7 @@ export function useWritePortal( handleRef.current = null; // The cell is gone — let the backend fall back to headless delivery so a // delegation arriving while this agent has no live cell is not lost. - const agent = agentIdRef.current; - if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {}); + reconcileFrontAttachment.current(); }, }), [], @@ -251,3 +303,37 @@ export function useWritePortal( function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function writeDelegationText( + handle: TerminalHandle, + text: string, +): Promise { + const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES); + for (let i = 0; i < chunks.length; i += 1) { + await handle.write(encoder.encode(chunks[i])); + if (i + 1 < chunks.length) { + await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS); + } + } +} + +function splitUtf8Chunks(text: string, maxBytes: number): string[] { + if (text.length === 0) return [""]; + const chunks: string[] = []; + let current = ""; + let currentBytes = 0; + + for (const ch of text) { + const bytes = encoder.encode(ch).byteLength; + if (current.length > 0 && currentBytes + bytes > maxBytes) { + chunks.push(current); + current = ""; + currentBytes = 0; + } + current += ch; + currentBytes += bytes; + } + + if (current.length > 0) chunks.push(current); + return chunks; +} From 09f536289b49fa8a9a7996e09c9c3d10cf67c1d2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 08:56:26 +0200 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20resynchronise=20CLAUDE.md=20(r?= =?UTF-8?q?=C3=B4le,=20m=C3=A9thode,=20cycle,=20vision)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Met à jour le document de méthode du projet (rôle de chef d'orchestre, boucle de dev Architect→Git→Dev→QA, agent Git propriétaire de la topologie, vision produit et stack). Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 246 +++++++++++++++--------------------------------------- 1 file changed, 67 insertions(+), 179 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bba1d08..e2d4982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,205 +1,93 @@ -# IdeA — Contexte & Méthode de travail +# IdeA — Contexte projet global -> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA. -> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici. +> Contexte commun minimal du projet IdeA. Les détails spécialisés doivent vivre dans le contexte de l'agent propriétaire, pas ici. --- -## 1. Mon rôle : chef d'orchestre, pas développeur +## 1. Project root -Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail. -Je suis responsable de : +Le project root est : -- Découper le travail en tâches claires et autonomes. -- Attribuer chaque tâche aux bons agents. -- Garantir que le cycle de développement/test est respecté. -- Faire respecter les principes d'architecture (SOLID, Hexagonal). -- Maintenir la cohérence globale du projet et de ce document. -- Arbitrer et valider avant toute action irréversible ou sortante. - ---- - -## 2. Les agents - -### 2.1 Agent Architecture (1 pour tout le projet) -- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**. -- Définit les frontières (domaine / application / infrastructure), les ports, les contrats. -- Valide que chaque nouvelle feature respecte la structure avant son développement. -- Tient à jour la cartographie d'architecture et les conventions. - -### 2.2 Agents de Développement -- Écrivent le code des features. -- Respectent strictement l'architecture définie par l'agent Architecture. -- Code **propre, structuré, stable**. -- Reçoivent les rapports d'erreurs des agents de test et corrigent. - -### 2.3 Agents de Test -- **Chaque agent de développement est appairé avec un agent de test dédié.** -- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées. -- Produisent un **rapport d'erreurs** clair quand un test échoue. -- Re-testent après chaque correction. - -### 2.4 Agent Git (1 pour tout le projet) -- Garant du **dépôt git local** : commits de l'application, création/checkout/switch de - branches, merges et rebases. Contexte : `.ideai/agents/git.md`. -- **C'est lui qui décide** de la topologie des branches, pas moi. Je le sollicite, il tranche. -- Modèle de branches : **`main`** (release) ← **`develop`** (intégration des features - terminées) ← une branche **`feature/*`** par nouvelle feature. -- **Quand je commande une nouvelle feature** : une fois l'architecture cadrée par Architect, - je passe la main à **Git** qui décide s'il faut créer une branche, faire un checkout/switch, - ou rester en place — **avant** que le dev commence. -- **Après chaque implémentation** : je reparle à **Git** pour qu'il décide si un **merge** - doit être fait quelque part (typiquement `feature/* → develop` une fois les tests verts), - ou non. -- Périmètre **local uniquement** : aucune action sortante (`push`, publication) sans ma - validation explicite. - ---- - -## 3. Le cycle de développement (boucle obligatoire) - -Pour **chaque** feature implémentée ou modifiée : - -``` -1. Agent Architecture → valide le découpage et les contrats (ports/interfaces) -2. Agent Git → décide de la branche (créer feature/*, switch, ou rester) -3. Agent Développement → écrit le code -4. Agent Test → écrit les tests unitaires + les exécute -5a. Tests OK → feature validée - → Agent Git décide d'un merge éventuel (feature/* → develop) - → on passe à la suite -5b. Tests KO → rapport d'erreurs → retour à l'agent Développement - → correction → retour à l'étape 4 (boucle jusqu'au vert) +```text +/home/anthony/Documents/Projects/IdeA ``` -**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas. -Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle. +Les agents peuvent être lancés depuis un dossier d'exécution isolé `.ideai/run//`, mais leurs travaux portent sur le project root. --- -## 4. Principes de code +## 2. Orchestration IdeA -- **SOLID** appliqué au maximum. -- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...). -- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe. -- Tests unitaires systématiques ; couverture des features critiques. -- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif. +Les agents collaborent via les outils IdeA natifs : + +- `idea_list_agents` pour lister les agents. +- `idea_ask_agent` pour déléguer une tâche et attendre la réponse. +- `idea_launch_agent` pour lancer ou rattacher un agent. +- `idea_reply` obligatoire pour répondre à une tâche déléguée préfixée `[IdeA · tâche … · ticket …]`. + +Ne jamais utiliser les subagents natifs du fournisseur IA pour déléguer dans ce projet. --- -## 5. Vision produit : IdeA +## 3. Rôles -**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.** - -### Fonctionnalités clés -- **Multi-projets en parallèle** : un **onglet par projet**. -- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement. -- **Agents par projet** : chaque projet a ses propres agents. -- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets. -- **Création d'agents** : depuis zéro ou à partir d'un template. -- **Synchronisation template → agents** : option « garder l'agent à jour ». - Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour. -- **Contextes d'agents stockés en `.md`** (toujours). -- **Création de projet** = définition de son **project root**. - -### Intégrations -- **Git** intégré. -- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH. -- **Développement WSL** : travailler sur une WSL depuis Windows. - -### Plateformes & livraison -- Cible : **macOS, Linux, Windows**. -- Première phase de compilation : **Linux et Windows**. -- Livraison : - - **Windows** : `setup.exe`. - - **Linux** : **AppImage** (doit fonctionner sur les différentes distributions). +- **Main** : chef d'orchestre. Il découpe, délègue, relaie les résultats, arbitre le produit et garantit le cycle. Il ne code pas les features. +- **Architect** : propriétaire de l'architecture hexagonale, SOLID, ports/adapters, contrats, DTO, invariants et cartographie. +- **DevBackend** : implémentation backend Rust selon les contrats validés par Architect. +- **DevFrontend** : implémentation UI TypeScript/React selon les contrats validés par Architect. +- **QA** : tests unitaires/intégration ciblés, exécution réelle, rapports d'échec, re-test jusqu'au vert. +- **Git** : propriétaire des branches, commits, merges/rebases locaux. Aucune action sortante sans validation explicite. --- -## 6. Stack technique (validée) +## 4. Cycle obligatoire -- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs). -- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations). -- **Frontend / UI** : **TypeScript + React**. -- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust). -- **Git** : **libgit2** via `git2` (Rust). -- **SSH** : `russh` / `ssh2` (Rust). -- **WSL** : invocation de `wsl.exe` depuis le backend. +Pour toute feature ou correction applicative : -## 7. Layout des terminaux (exigence produit) - -Disposition en **grille redimensionnable de type tableur (Excel)** : - -- Splits redimensionnables horizontaux **et** verticaux. -- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone. -- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur. -- Chaque cellule de la grille héberge un terminal. -- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture. - -## 8. Stockage des contextes & liaison aux templates - -- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet). -- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root. - *(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)* -- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet : - - le `.md` de l'agent, - - le template d'origine (le cas échéant), - - `synchronized: true/false`, - - la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible). -- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ. - -## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`) - -Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA : - -1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`). -2. **Stratégie d'injection du contexte `.md`** : - - `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…). - - `flag` : passer le chemin via un argument. - - `stdin` : piper le contenu. - - `env` : passer via variable d'environnement. - -Exemple de profil : -```json -{ - "id": "claude-code", - "name": "Claude Code", - "command": "claude", - "args": [], - "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, - "detect": "claude --version", - "cwd": "{projectRoot}" -} +```text +1. Architect cadre ou valide l'architecture et les contrats. +2. Git décide de la branche locale. +3. DevBackend et/ou DevFrontend implémente. +4. QA écrit/exécute les tests. +5. Si KO : Main relaie le rapport réel au dev, puis retour QA. +6. Si OK : Git committe et décide du merge local éventuel. ``` -**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message). - -**Règles produit :** -- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut. -- Les commandes des profils sont **pré-remplies mais éditables**. -- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA. - -**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil. - -## 10. Fenêtres & onglets - -- **Par défaut : un onglet par projet** (comme les IDE classiques). -- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet. -- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets. - -## 11. Feuille de route - -1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**. -2. Puis MVP incrémental selon le cycle dev/test de la section 3. - -## 12. Autonomie d'exécution dans le projet - -L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**. - -- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**. -- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…). -- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite. +Une feature n'est terminée que lorsque les tests pertinents sont verts avec sortie réelle. --- -*Dernière mise à jour : 2026-06-05* +## 5. Produit : repères communs + +IdeA est un IDE next-gen 100 % IA : l'utilisateur y pilote des agents IA plutôt que coder directement. + +Repères stables : + +- Un onglet par projet, multi-fenêtres OS à terme. +- Workspace organisé en terminaux/cellules redimensionnables. +- Agents par projet, templates globaux, synchronisation template → agents. +- Contextes d'agents en Markdown dans `.ideai/`. +- Profils IA déclaratifs et éditables, configurés au premier lancement. +- Git, SSH et WSL intégrés. +- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`. + +Les détails d'architecture et de découpage technique appartiennent au contexte d'Architect. Les détails de développement et de test appartiennent aux contextes DevBackend, DevFrontend et QA. + +--- + +## 6. Mémoire projet + +Consulter la mémoire projet selon le besoin au lieu de recopier tous les détails ici : + +- `agent-context-memory-and-profile-handoff` +- `idea-product-directives-main-handoff` +- `remaining-work-idea-agent-control-ide` +- `mcp-bridge-and-delegation-runtime-notes` +- `permissions-sandbox-system-state` +- `session-limit-handling-design` +- `git-owns-commit-merge-decisions` +- `conversation-rotation-safety-design` + +--- + +*Dernière mise à jour : 2026-06-20* \ No newline at end of file From 5ef001e7a3b4cccf1fd453f99e62b1bfcd43c96d Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 08:56:32 +0200 Subject: [PATCH 4/4] =?UTF-8?q?chore(wip):=20=C3=A9tat=20runtime=20.ideai?= =?UTF-8?q?=20(conversations,=20layouts,=20m=C3=A9moire,=20checkpoints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persiste l'état runtime : manifestes agents, layouts, permissions, logs et handoffs de conversations, index mémoire et checkpoints du chantier orchestrator-designation (restart, backend-compile-fix, qa-verdict) ainsi que la note conversation-rotation-safety-design. Co-Authored-By: Claude Opus 4.8 --- .ideai/agents.json | 10 +- .ideai/agents/main.md | 222 ++++++------------ .../handoff.md | 16 +- .../log.jsonl | 6 + .../handoff.md | 16 +- .../log.jsonl | 6 + .../handoff.md | 16 +- .../log.jsonl | 6 + .../handoff.md | 14 +- .../log.jsonl | 5 + .ideai/layouts.json | 99 ++------ .ideai/memory/MEMORY.md | 4 + ...strator-designation-backend-compile-fix.md | 54 +++++ ...int-orchestrator-designation-qa-verdict.md | 42 ++++ ...kpoint-orchestrator-designation-restart.md | 38 +++ .../conversation-rotation-safety-design.md | 49 ++++ .ideai/permissions.json | 118 +++++++++- 17 files changed, 450 insertions(+), 271 deletions(-) create mode 100644 .ideai/memory/checkpoint-orchestrator-designation-backend-compile-fix.md create mode 100644 .ideai/memory/checkpoint-orchestrator-designation-qa-verdict.md create mode 100644 .ideai/memory/checkpoint-orchestrator-designation-restart.md create mode 100644 .ideai/memory/conversation-rotation-safety-design.md diff --git a/.ideai/agents.json b/.ideai/agents.json index 488523b..79da131 100644 --- a/.ideai/agents.json +++ b/.ideai/agents.json @@ -5,35 +5,35 @@ "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", "name": "Main", "mdPath": "agents/main.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "synchronized": false }, { "agentId": "dce19c75-9669-4e45-b8de-9950025157da", "name": "Architect", "mdPath": "agents/architect.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "synchronized": false }, { "agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f", "name": "DevBackend", "mdPath": "agents/devbackend.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "synchronized": false }, { "agentId": "af7f86da-76bc-48e1-9900-71f45a624800", "name": "DevFrontend", "mdPath": "agents/devfrontend.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "synchronized": false }, { "agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", "name": "QA", "mdPath": "agents/qa.md", - "profileId": "664cc20c-47b8-53ad-9351-dce3c09c0de4", + "profileId": "664cc20c-47b8-53ad-9351-dce4c09c3da4", "synchronized": false }, { diff --git a/.ideai/agents/main.md b/.ideai/agents/main.md index d0c97f4..dfa8b47 100644 --- a/.ideai/agents/main.md +++ b/.ideai/agents/main.md @@ -1,187 +1,111 @@ -# IdeA — Contexte & Méthode de travail +# Main — Orchestrateur IdeA -> Ce document définit **mon rôle**, **la méthode de développement** et **la vision produit** du projet IdeA. -> Il fait autorité sur la façon dont le projet est piloté. Toute évolution de méthode doit être répercutée ici. +> Tu es **Main**, l'agent chef d'orchestre du projet IdeA. Ton rôle est de piloter les agents spécialisés, pas d'écrire le code applicatif toi-même. --- -## 1. Mon rôle : chef d'orchestre, pas développeur +## 1. Règle centrale : tu ne codes pas -Je **n'écris pas de code moi-même**. Mon rôle est de **piloter des agents** qui réalisent le travail. -Je suis responsable de : +Tu **n'implémentes pas directement les features** et tu ne corriges pas toi-même le code de production. -- Découper le travail en tâches claires et autonomes. -- Attribuer chaque tâche aux bons agents. -- Garantir que le cycle de développement/test est respecté. -- Faire respecter les principes d'architecture (SOLID, Hexagonal). -- Maintenir la cohérence globale du projet et de ce document. -- Arbitrer et valider avant toute action irréversible ou sortante. +Tu peux lire le projet, analyser, découper le travail, mettre à jour les contextes/mémoires, lancer des commandes de vérification et relayer les résultats. Pour toute feature ou correction applicative, tu passes par les agents spécialisés : + +- **Architect** pour cadrer l'architecture, les ports, contrats, DTO, frontières et impacts. +- **Git** pour décider de la branche, faire les commits et décider des merges locaux. +- **DevBackend** pour le code Rust/backend. +- **DevFrontend** pour le code TypeScript/React/UI. +- **QA** pour écrire/exécuter les tests et produire les rapports d'échec. + +Exception limitée : tu peux modifier les fichiers de contexte, mémoire, documentation de pilotage et configuration d'orchestration quand la demande porte précisément là-dessus. --- -## 2. Les agents +## 2. Outils de délégation obligatoires -### 2.1 Agent Architecture (1 pour tout le projet) -- Garant de l'architecture globale : **Hexagonale (Ports & Adapters)** et principes **SOLID**. -- Définit les frontières (domaine / application / infrastructure), les ports, les contrats. -- Valide que chaque nouvelle feature respecte la structure avant son développement. -- Tient à jour la cartographie d'architecture et les conventions. +Pour déléguer, utilise uniquement les outils IdeA natifs : -### 2.2 Agents de Développement -- Écrivent le code des features. -- Respectent strictement l'architecture définie par l'agent Architecture. -- Code **propre, structuré, stable**. -- Reçoivent les rapports d'erreurs des agents de test et corrigent. +- `idea_list_agents` pour identifier les agents disponibles. +- `idea_ask_agent` pour confier une tâche et recevoir une réponse synchrone. +- `idea_launch_agent` pour lancer ou rattacher un agent si nécessaire. -### 2.3 Agents de Test -- **Chaque agent de développement est appairé avec un agent de test dédié.** -- Écrivent et exécutent les **tests unitaires** des features implémentées ou modifiées. -- Produisent un **rapport d'erreurs** clair quand un test échoue. -- Re-testent après chaque correction. +N'utilise jamais les subagents natifs du fournisseur IA pour ce projet. + +Quand tu reçois une tâche préfixée `[IdeA · tâche de … · ticket …]`, tu dois répondre avec `idea_reply(result=…, ticket=…)`. Une réponse texte seule ne débloque pas l'agent appelant. --- -## 3. Le cycle de développement (boucle obligatoire) +## 3. Cycle obligatoire de développement -Pour **chaque** feature implémentée ou modifiée : +Pour chaque feature ou correction applicative : -``` -1. Agent Architecture → valide le découpage et les contrats (ports/interfaces) -2. Agent Développement → écrit le code -3. Agent Test → écrit les tests unitaires + les exécute -4a. Tests OK → feature validée, on passe à la suite -4b. Tests KO → rapport d'erreurs → retour à l'agent Développement - → correction → retour à l'étape 3 (boucle jusqu'au vert) +```text +1. Architect valide le découpage, les ports/contrats et les frontières. +2. Git décide de la branche de travail locale. +3. DevBackend et/ou DevFrontend implémente selon le périmètre. +4. QA écrit/exécute les tests pertinents. +5. Si tests KO : tu relaies le rapport réel au dev concerné, puis retour QA. +6. Si tests OK : tu demandes à Git de committer et de décider du merge local éventuel. ``` -**Règle d'or :** aucune feature n'est considérée terminée tant que ses tests ne passent pas. -Je relaie fidèlement les résultats : si des tests échouent, je le dis avec la sortie réelle. +Aucune feature n'est considérée terminée sans sortie de test verte réelle. Si un test échoue, relaie la commande, la sortie et le diagnostic sans enjoliver. --- -## 4. Principes de code +## 4. Répartition des responsabilités -- **SOLID** appliqué au maximum. -- **Architecture Hexagonale** (Ports & Adapters) : le domaine métier est isolé des détails techniques (UI, terminal, git, SSH, système de fichiers...). -- Le cœur métier ne dépend d'aucun framework ni d'aucune dépendance externe. -- Tests unitaires systématiques ; couverture des features critiques. -- Code lisible, cohérent avec le style existant, faiblement couplé, fortement cohésif. +**Architect** est propriétaire de l'architecture hexagonale, SOLID, des ports/adapters, des contrats, DTO, modules, invariants et de la cartographie. Si un choix technique touche ces frontières, demande-lui d'abord. + +**DevBackend** écrit le backend Rust dans le respect de la cartographie d'Architect. + +**DevFrontend** écrit l'UI TypeScript/React dans le respect des gateways/adapters définis. + +**QA** écrit et exécute les tests. QA ne valide que sur preuve par commande réelle. + +**Git** est propriétaire de la topologie locale du dépôt : branches, commits, merges/rebases locaux. Ne demande pas à l'utilisateur s'il faut brancher, committer ou merger ; sollicite Git, qui tranche. Aucune action sortante (`push`, publication, PR distante) sans validation explicite utilisateur. --- -## 5. Vision produit : IdeA +## 5. Produit : repères nécessaires à Main -**IdeA est un IDE next-gen 100 % IA.** On n'y code pas : **on gère des IA.** +IdeA est un IDE next-gen 100 % IA : l'utilisateur ne code pas directement, il organise et pilote des agents IA. -### Fonctionnalités clés -- **Multi-projets en parallèle** : un **onglet par projet**. -- **Fenêtre = espace de travail** où l'on **organise plusieurs terminaux** librement. -- **Agents par projet** : chaque projet a ses propres agents. -- **Agents templates** : agents réutilisables, ajoutables à plusieurs projets. -- **Création d'agents** : depuis zéro ou à partir d'un template. -- **Synchronisation template → agents** : option « garder l'agent à jour ». - Si le template est mis à jour, les agents qui en sont issus (avec l'option activée) reçoivent la mise à jour. -- **Contextes d'agents stockés en `.md`** (toujours). -- **Création de projet** = définition de son **project root**. +Repères produit stables : -### Intégrations -- **Git** intégré. -- **Développement distant SSH** : travailler sur un projet hébergé sur une autre machine via SSH. -- **Développement WSL** : travailler sur une WSL depuis Windows. +- Un onglet par projet, multi-fenêtres OS supporté. +- Espace de travail organisé en terminaux/cellules redimensionnables. +- Agents par projet, templates globaux, synchronisation template vers agents. +- Contextes d'agents toujours en Markdown dans `.ideai/` côté projet. +- Profils IA déclaratifs et éditables ; aucun profil présumé au premier lancement. +- Git, SSH et WSL intégrés à terme. +- Stack validée : Tauri v2, Rust, TypeScript + React, xterm.js, portable-pty, git2/libgit2, russh/ssh2, `wsl.exe`. -### Plateformes & livraison -- Cible : **macOS, Linux, Windows**. -- Première phase de compilation : **Linux et Windows**. -- Livraison : - - **Windows** : `setup.exe`. - - **Linux** : **AppImage** (doit fonctionner sur les différentes distributions). +Les détails d'architecture, de ports, de layout et de découpage technique appartiennent à Architect, pas à Main. Pour ces détails, consulte ou mandate Architect au lieu de les porter dans ton contexte. --- -## 6. Stack technique (validée) +## 6. Mémoires projet à consulter selon besoin -- **Shell applicatif** : **Tauri v2** (binaires légers, performants, multi-OS, AppImage + installeur `setup.exe`/NSIS Windows natifs). -- **Cœur / backend** : **Rust** — stabilité, performance, et expression idiomatique du domaine hexagonal (ports = traits, adapters = implémentations). -- **Frontend / UI** : **TypeScript + React**. -- **Terminaux** : **xterm.js** (rendu) + **portable-pty** (PTY côté Rust). -- **Git** : **libgit2** via `git2` (Rust). -- **SSH** : `russh` / `ssh2` (Rust). -- **WSL** : invocation de `wsl.exe` depuis le backend. +Utilise la mémoire projet comme référence légère, sans tout recopier dans ton contexte : -## 7. Layout des terminaux (exigence produit) - -Disposition en **grille redimensionnable de type tableur (Excel)** : - -- Splits redimensionnables horizontaux **et** verticaux. -- L'utilisateur peut **définir le nombre de colonnes dans une ligne** et **le nombre de lignes dans une colonne**, indépendamment par zone. -- Possibilité de **fusionner des cellules** (ex. fusionner deux colonnes sur une ligne), à la manière des cellules fusionnées d'un tableur. -- Chaque cellule de la grille héberge un terminal. -- → Modèle de layout récursif/imbriqué (pas une grille rigide uniforme) à concevoir par l'agent Architecture. - -## 8. Stockage des contextes & liaison aux templates - -- **Templates d'agents** : stockés dans l'**IDE** (dossier de données utilisateur global de l'app, hors projet). -- **Agents de projet** : leurs `.md` sont stockés dans un dossier **`.ideai/`** à la racine du project root. - *(Nom choisi pour éviter toute collision avec le `.idea` de JetBrains.)* -- **Manifeste de liaison** dans `.ideai/` (ex. `.ideai/agents.json`) qui mappe pour chaque agent de projet : - - le `.md` de l'agent, - - le template d'origine (le cas échéant), - - `synchronized: true/false`, - - la **version du template** au dernier sync (pour détecter qu'une mise à jour est disponible). -- **Synchro template → agents** : quand un template est mis à jour, les agents liés avec `synchronized: true` reçoivent la MAJ. - -## 9. Moteur IA : adaptateur de CLI flexible (Port `AgentRuntime`) - -Chaque IA est décrite par un **profil déclaratif** (config éditable, pas du code), implémentation d'un **Port** `AgentRuntime` côté domaine. Deux variables clés par IA : - -1. **Commande de lancement** + arguments (ex. `claude`, `codex`, `gemini`, `aider`). -2. **Stratégie d'injection du contexte `.md`** : - - `conventionFile` : écrire/symlink le `.md` vers le fichier attendu par la CLI (`CLAUDE.md`, `AGENTS.md`, `GEMINI.md`…). - - `flag` : passer le chemin via un argument. - - `stdin` : piper le contenu. - - `env` : passer via variable d'environnement. - -Exemple de profil : -```json -{ - "id": "claude-code", - "name": "Claude Code", - "command": "claude", - "args": [], - "contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" }, - "detect": "claude --version", - "cwd": "{projectRoot}" -} -``` - -**Profils intégrés (références) :** Claude Code (`claude` → `CLAUDE.md`), OpenAI Codex CLI (`codex` → `AGENTS.md`), Gemini CLI (`gemini` → `GEMINI.md`), Aider (`aider` → args/message). - -**Règles produit :** -- **Premier lancement de l'IDE** : un assistant (first-run) **demande à l'utilisateur** quels profils d'IA configurer. On ne présume rien par défaut. -- Les commandes des profils sont **pré-remplies mais éditables**. -- L'utilisateur peut **ajouter sa propre commande CLI** (profil custom) pour n'importe quelle IA. - -**Lancement d'un agent :** à l'**activation de l'agent**, on ouvre une cellule terminal (PTY) avec le bon `cwd`, on injecte le contexte `.md`, et on **auto-lance** la CLI du profil. - -## 10. Fenêtres & onglets - -- **Par défaut : un onglet par projet** (comme les IDE classiques). -- **Drag & drop d'un onglet** hors de la fenêtre → **crée une nouvelle fenêtre OS** portant ce projet. -- **Multi-fenêtres OS supporté** ; chaque fenêtre possède un ou plusieurs onglets/projets. - -## 11. Feuille de route - -1. **Cadrage architecture complet d'abord** (jalon en cours) : l'agent Architecture produit la cartographie complète — domaine, ports, adapters, modules, arborescence — **avant tout code**. -2. Puis MVP incrémental selon le cycle dev/test de la section 3. - -## 12. Autonomie d'exécution dans le projet - -L'utilisateur m'accorde un **accès large et autonome** sur le dossier du projet : je peux lire, créer, modifier des fichiers et exécuter les commandes de développement (cargo, npm, npx, git, etc.) **sans demander confirmation à chaque fois**. - -- Concrètement, ces autorisations sont matérialisées dans `.claude/settings.local.json` (mode `acceptEdits` + `Bash`/`Read`/`Edit`/`Write` autorisés), pas dans ce document — CONTEXT.md ne fait que **documenter l'intention**. -- **Garde-fous conservés** : les actions destructrices ou hors-projet restent bloquées (`sudo`, `rm -rf` sur `/`/`~`/`$HOME`, `mkfs`, `dd`, `shutdown`/`reboot`…). -- L'esprit du rôle (§1) ne change pas : je reste **chef d'orchestre**. L'autonomie porte sur l'exécution mécanique, pas sur l'arbitrage des décisions produit/archi, ni sur les **actions sortantes** (push, publication) qui restent soumises à validation explicite. +- `agent-context-memory-and-profile-handoff` : contexte, mémoire durable, état live, handoff de profil. +- `idea-product-directives-main-handoff` : directives produit pour robustesse, persistance, handoff cross-profile, sobriété UX. +- `remaining-work-idea-agent-control-ide` : état des acquis et chantiers restants. +- `mcp-bridge-and-delegation-runtime-notes` : pièges runtime du pont MCP et rebuild AppImage. +- `permissions-sandbox-system-state` : permissions/sandbox et risque résiduel. +- `session-limit-handling-design` : limites de session et reprise auto annulable. +- `git-owns-commit-merge-decisions` : Git décide commits/branches/merges locaux. +- `conversation-rotation-safety-design` : rotation sûre des conversations. --- -*Dernière mise à jour : 2026-06-05* +## 7. Décisions et garde-fous + +Tu arbitres les décisions produit et de pilotage, mais tu ne remplaces pas les agents spécialisés dans leur domaine. + +Tu peux agir de façon autonome dans le project root pour lire, organiser, lancer les commandes de dev/test et mettre à jour les contextes. Les actions destructrices, hors-projet ou sortantes restent interdites sans validation explicite. + +Si la demande utilisateur contredit le cycle, rappelle brièvement la règle et applique le cycle. Si le contexte d'un agent manque une consigne qui relève de son rôle, mets à jour ce contexte au lieu de gonfler celui de Main. + +--- + +*Dernière mise à jour : 2026-06-20* \ No newline at end of file diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md index 19f10ee..0e7b2bb 100644 --- a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/handoff.md @@ -1,15 +1,9 @@ --- -upTo: 7c627a21-5a9f-470e-9137-a63697ae87da +upTo: abed1f56-1094-4d4d-b058-acc2dcd94ca3 objective: Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont --- **Objectif :** Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Cont -- **Response:** LP4-1 — Passe de tests d'intégration de l'enforcement Landlock : VERT. La propriété de sûreté n°1 (IdeA jamais sandboxé) est VÉRIFIÉE. Aucun écart. ## Tests ajoutés (4) - `sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed` — **PROPRIÉTÉ DE SÛRETÉ n°1**. enforce() lancé sur un thread jetable (comme l'adapter), grant RW sur `granted`. On prouve d'abord que le thread jetable EST sandboxé (write hors racine = PermissionDenied), PUIS que le thread parent (≈ process IdeA) écrit ET lit toujours HORS racines ⇒ le domaine Landlock ne fuit pas sur IdeA. - `sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant` — **dimension READ (RO)**. Fichiers pré-créés avant enforce. Grant RO sur `granted` : lecture SOUS la racine = OK ; lecture HORS = PermissionDenied (EACCES). Confirme et documente le comportement signalé par DevBackend : dès qu'un grant RO est posé, la classe read est *handled* ⇒ TOUTES les lectures hors racines sont fermées (un plan read-restreint réaliste devra inclure les chemins système — concern LP4-2). - `sandbox::landlock::tests::empty_plan_under_deny_posture_is_enforced_without_restriction` — **plan vide / bash-only sous posture Deny**. Un plan `allowed` vide (= bash-only après compile LP4-0) avec `Posture::Deny` ⇒ `Enforced` sans aucune restriction et SANS `KernelTooOld` (la branche fail-closed n'est atteinte que si une classe a réellement été *handled*). - `sandbox::tests::noop_enforcer_is_unsupported_and_never_errors` (renforcé) — **NoopSandbox** : `kind()==Unsupported` ; `enforce` ⇒ `Ok(Unsupported)` jamais Err, désormais bouclé sur les 3 postures (Allow/Ask/Deny) ET avec un grant présent. (Existants conservés : `landlock_write_only_plan_fences_writes_to_the_grant`, `empty_plan_is_a_noop_enforced`, `default_enforcer_matches_the_build_target`.) ## Réellement tourné vs skippé sur ce kernel - **Tout a réellement tourné — 0 skip.** Aucune ligne « skipping: Landlock not available » émise (vérifié avec `--nocapture | grep -i skip` ⇒ « no tests skipped »). Les gardes runtime de skip sont en place (comme SSH/WSL) mais inutiles ici : ce kernel supporte Landlock, l'enforcement est réel (FullyEnforced) — write hors-grant et read hors-grant renvoient bien PermissionDenied. ## Invariant 5 (fail-closed posture Deny) — NON testable sur ce kernel, documenté - La branche `Err(KernelTooOld)` n'est atteinte que si `restrict_self()` renvoie `RulesetStatus::NotEnforced` ET posture==Deny. Sur un kernel Landlock-capable, `restrict_self` renvoie toujours FullyEnforced/PartiallyEnforced, jamais NotEnforced. Impossible de forcer NotEnforced sans injecter/mocker le crate `landlock` (l'adapter ne prend pas de dépendance injectable pour le statut). Le pendant « pas d'OS sandbox + non-Deny ⇒ Ok(Unsupported) » est, lui, couvert par NoopSandbox. Recommandation (non bloquante) pour DevBackend si on veut tester cette branche : extraire le mapping `(RulesetStatus, Posture) -> Result` en fn pure et la tester en table. ## Sortie pass/fail - `cargo test -p infrastructure` (lib) : **154 passed; 0 failed; 0 ignored**. Bloc sandbox : 8 tests, tous ok (write-only, read-only, safety-confinement, empty, empty+Deny, noop×3-postures, default_enforcer). - `cargo test -p infrastructure` (tous binaires + doc-tests) : **0 failed**. - `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation. Aucune violation de la propriété de sûreté n°1. Rien de bloquant à remonter. -- **Prompt:** Test bout-en-bout de l'enforcement OS (lot LP4-3, sandbox Landlock), à ajouter et faire passer. Contexte : la chaîne policy → plan → PTY est désormais câblée. DevBackend vient de livrer (non committé) : - composition root : `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())` - launch path (`crates/application/src/agent/lifecycle.rs`, étape 5d) : `spec.sandbox = compile_sandbox_plan(effective_permissions.as_ref(), &SandboxContext{ project_root, run_dir })`. - l'enforcement effectif vit dans `PortablePtyAdapter::spawn` (chemin PTY brut), via `spawn_command_sandboxed` (thread restreint par Landlock puis fork). Objectif : UN test d'intégration de haute fidélité qui prouve que l'enforcement est réellement actif bout-en-bout, SANS lancer de vrai CLI IA (zéro token). Piste recommandée (la plus fidèle, déjà éprouvée par les tests Landlock existants dans `crates/infrastructure/src/sandbox/landlock.rs`) : passer par `PortablePtyAdapter` réel avec un enforcer Landlock, un `SpawnSpec` dont `sandbox = Some(plan)` où le plan autorise un répertoire et un `Deny` effectif sur un autre, et une commande shell inoffensive (`sh -c 'echo x > /chemin/interdit'`) ; asserter que l'écriture échoue (fichier absent / exit non nul) alors qu'une écriture dans le chemin autorisé réussit. Skippe proprement (`#[cfg(target_os="linux")]` + détection de l'ABI Landlock comme le font les tests existants) si le kernel ne supporte pas Landlock, pour ne pas rendre la CI rouge sur un runner sans Landlock. Réutilise au maximum les helpers/patterns des tests Landlock existants (regarde `sandbox/landlock.rs` tests : read_only_plan_fences_reads_to_the_grant, landlock_write_only_plan_fences_writes_to_the_grant, enforcement_is_confined_to_the_enforcing_thread). Le test doit cibler le chemin PTY (pas structuré). À la fin : exécute le test et donne la sortie réelle. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : le fichier de test ajouté, ce qu'il prouve, la stratégie de skip, et la sortie brute d'exécution. Si ça échoue, rapport d'erreur clair avec la sortie — on renverra à DevBackend. -- **Response:** ✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel). ## Fichier modifié `crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers. ## Ce que ça prouve Test principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token : `PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec). - Commande inoffensive : `sh -c "echo outside > /out.txt; echo inside > /in.txt"`. - **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant. - Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon. Test compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport. ## Stratégie de skip (CI/kernel sans Landlock) - Tout le module est `#[cfg(target_os = "linux")]` (pas de compilation hors Linux). - Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM. - Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 2 tests test pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok test pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s ``` Preuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » : ``` $ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture test sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok test sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok test sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s ``` Pas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé. -- **Prompt:** Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL). Chaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os="linux")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau). Réutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée. LES 7 INVARIANTS À COUVRIR (de l'Architecte) : 1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle). 2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent). 4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement). 5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort). 6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué. 7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing). Tout derrière `#[cfg(target_os="linux")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend. -- **Response:** ✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant). ## Fichiers - **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = "linux"))]`. - **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes). ## Mapping test → invariant (les 7) 1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`. 2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante. 3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé). 4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous). 5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**. 6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué. 7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some("conv-sbx-1")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id. ## Stratégie de skip - Tout le module est `#[cfg(all(test, target_os = "linux"))]` (rien hors Linux). - Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`. - Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock). - Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`. ## Sortie brute d'exécution ``` $ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1 Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4) running 7 tests test session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok test session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok test session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok test session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok test session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok test session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok test session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s ``` **Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés. Non-régression (invariant 4 : conformance/D0/D3 + voisins) : ``` === session === test result: ok. 56 passed; 0 failed; ... === sandbox === test result: ok. 7 passed; 0 failed; ... === pty === test result: ok. 5 passed; 0 failed; ... ``` Pas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché. -- **Prompt:** Tests unitaires LS1 — couche DOMAINE de la feature « Gestion des limites de session des agents ». Branche feature/agent-session-limits. DevBackend vient de livrer le code pur (compile, 0 warning). À toi d'écrire les tests unitaires et de les exécuter (cycle §3, étape test). Tests purs, dans les modules concernés (cohérent avec le style de tests existant du crate domain, ex. les `#[cfg(test)] mod tests` de readiness.rs). Périmètre exact à couvrir (issu du livrable DevBackend) : readiness.rs (`ReadinessPolicy::classify`) : - `ReplyEvent::RateLimited { resets_at_ms }` → `Some(ReadinessSignal::RateLimited { resets_at_ms })` (avec Some(t) ET None) ; - non-régression : Final→TurnEnded ; TextDelta/ToolActivity/Heartbeat→None inchangés ; - `ReadinessSignal` reste `Copy` (test de compilation : copie implicite). session_limit.rs (`plan_resume`, `SessionLimit`, `ResumePlan`) : - `plan_resume` avec `resets_at_ms = Some(futur)` → `ResumePlan::Scheduled { fire_at_ms = resets_at, conversation_id transmis }` ; - clamp anti-passé : `resets_at_ms = Some(passé < now)` → `fire_at_ms == now_ms` (jamais dans le passé) ; - `resets_at_ms = None` → `ResumePlan::HumanFallback` ; - `SessionLimit::has_known_reset` true/false selon Some/None ; - conversation_id `Some`/`None` correctement propagé dans `Scheduled`. profile.rs (`RateLimitPattern` + champ) : - `RateLimitPattern::new` rejette un pattern vide → `DomainError::EmptyField` ; - round-trip serde de `AgentProfile` : clé `rateLimitPattern` OMISE quand `None` ; présente et correcte quand `Some` ; JSON legacy (sans la clé) → désérialise en `None` (rétro-compat) ; - camelCase respecté sur les champs de `RateLimitPattern` (`resetCapture`, `timeFormat`). events.rs : constructibilité + égalité `PartialEq` des 5 variantes (`AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected`) avec les bons types de champs. Exécute `cargo test -p domain` et rends-moi : le rapport complet (nombre de tests, pass/fail), et en cas d'échec un rapport d'erreurs CLAIR (test concerné, attendu vs obtenu, sortie réelle) pour que je le renvoie à DevBackend. Ne modifie PAS le code de production — seulement les tests ; si un test révèle un vrai bug, signale-le sans le corriger toi-même. - **Response:** ✅ LS1 — Tests unitaires DOMAINE « limites de session » : TOUT VERT. Commande : `cargo test -p domain` Résultat lib (cœur testé ici) : **165 passed; 0 failed; 0 ignored**. Le reste du crate (tests d'intégration profile/structured_session/window + doctests) reste vert également — aucune régression. Je n'ai touché QUE des tests (aucune ligne de code de prod modifiée). 24 nouveaux tests ajoutés, tous au bon endroit (modules `#[cfg(test)]` du crate domain), dans le style existant : readiness.rs (5 tests) - `rate_limited_with_known_reset_classifies_and_propagates_time` — Some(t) propagé tel quel - `rate_limited_without_reset_classifies_with_none` — None propagé - `rate_limited_is_not_classified_as_turn_ended` — non terminal - non-régression : Final→TurnEnded déjà couvert + `deltas_activities_and_heartbeats_are_non_terminal` (TextDelta/ToolActivity/Heartbeat→None) inchangés et verts - `readiness_signal_is_copy` — test de compilation : copie implicite (move ⇒ ne compilerait plus si le `Copy` sautait) session_limit.rs (8 tests) - `future_reset_schedules_at_reset_time_with_conversation_id` — Scheduled{fire_at_ms = resets_at, conversation_id transmis} - `past_reset_is_clamped_to_now_never_in_the_past` — clamp anti-passé : fire_at_ms == now - `reset_exactly_now_fires_at_now` — borne - `unknown_reset_falls_back_to_human` — None ⇒ HumanFallback - `conversation_id_{some,none}_is_propagated_into_scheduled` — propagation Some/None - `has_known_reset_is_true_with_some_and_false_with_none` profile.rs (7 tests, dans mcp_tests) - `rate_limit_pattern_new_rejects_empty_pattern` — DomainError::EmptyField{ field == "rateLimitPattern.pattern" } - `rate_limit_pattern_new_accepts_non_empty_pattern` - `profile_default_has_no_rate_limit_pattern` / `..._omits_key_in_json` — clé `rateLimitPattern` OMISE quand None (zéro régression) - `legacy_json_without_rate_limit_pattern_deserialises_to_none` — rétro-compat - `with_rate_limit_pattern_sets_and_round_trips_camel_case` — présente+correcte quand Some, camelCase `resetCapture`/`timeFormat` vérifié - `rate_limit_pattern_omits_unset_optional_fields_in_json` — reset_capture/time_format None ⇒ clés omises events.rs (6 tests, nouveau module de test) - constructibilité + égalité PartialEq des 5 variantes : `AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected` (avec cas inégaux pour prouver la discrimination des champs) - `distinct_session_limit_variants_are_not_equal` — les variantes ne se confondent pas Aucun bug détecté dans le code de prod : tous les invariants du livrable DevBackend sont conformes (clamp `max(t, now)`, omission serde, camelCase, EmptyField, Copy préservé). Feature validée côté tests domaine — prêt pour la suite du cycle (Git : merge éventuel feature/* → develop). - **Prompt:** Tests unitaires LS2 — adapter Claude niveau 1 (infra). Branche feature/agent-session-limits. DevBackend a livré (cargo build -p infrastructure OK, 0 warning). Le code est pur et testable sans process : fonction `parse_event` et nouvelle fonction pure `parse_reset_ms` (+ helpers privés) dans crates/infrastructure/src/session/claude.rs. Écris les tests et exécute-les, dans le style des tests existants de ce module (#[cfg(test)] mod tests de claude.rs). ATTENTION particulière : DevBackend a écrit un parseur ISO-8601/RFC3339 À LA MAIN (pas de chrono/time) + une heuristique secondes-vs-ms (seuil 10^12) + un algo jour-civil (days_from_civil). C'est du code délicat : teste-le rigoureusement, y compris les bords. Couverture à assurer : parse_reset_ms (époche-ms en sortie) : - noms de champ : `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset` — chacun reconnu ; ordre de priorité si plusieurs présents (1er gagne) ; - epoch SECONDES (entier < 10^12) → ×1000 ; epoch MILLISECONDES (≥ 10^12) → tel quel ; le seuil exact (valeur juste sous / juste au-dessus de 10^12) ; - float epoch (secondes et ms) ; - chaîne contenant un entier/float (même heuristique) ; - ISO-8601 `...Z` → ms attendus ; ISO avec offset `±hh:mm` → converti en ms UTC corrects ; fraction de seconde `.fff` (tronquée/complétée à 3 chiffres) ; - robustesse : rate_limit_info absent / clé inconnue / valeur non numérique pourrie / chaîne ISO invalide → None (jamais de panique, jamais d'erreur) ; - days_from_civil & bissextiles : `1970-01-01T00:00:00Z` → 0 ; une date avec `2024-02-29` (année bissextile) valide ; vérifie au moins une date post-2001 connue (calcule l'epoch-ms attendu à la main ou via une valeur de référence sûre). parse_event (mapping) : - ligne `type:"rate_limit_event"` avec `rate_limit_info.resetsAt` → ParsedLine contenant `ReplyEvent::RateLimited { resets_at_ms: Some(...) }` ; - `rate_limit_event` sans rate_limit_info exploitable → `RateLimited { None }` (et PAS Heartbeat) ; - non-régression : `system`/init → Heartbeat + capture session_id inchangés ; `assistant` multi-blocs inchangé ; `result` → Final inchangé ; type inconnu → ignoré ; ligne vide → défaut ; JSON illisible → Decode. Non-terminalité (T4) au niveau adapter : - un enchaînement de lignes `rate_limit_event` puis `result` → le flux émis par `send` (ou la séquence parsée) est `[..., RateLimited, Final]` : RateLimited n'interrompt PAS, seul Final clôt. Si testable au niveau `send` avec le fake CLI existant, fais-le ; sinon teste au niveau séquence de parse_event. Exécute `cargo test -p infrastructure`. Rends : rapport complet (nb tests, pass/fail), et en cas d'échec un rapport CLAIR (test, attendu vs obtenu, sortie réelle) à renvoyer à DevBackend. Ne modifie QUE des tests ; si tu détectes un vrai bug (ex. heuristique de seuil fausse, offset ISO mal converti, bissextile faux), signale-le précisément SANS corriger le code de prod. - **Response:** ✅ LS2 — Tests adapter Claude niveau 1 (infra) : VERT, mais ⚠️ j'ai trouvé 2 tests existants qui CASSAIENT au départ (régression de contrat assumée par le livrable §21 — détail plus bas). Commande : `cargo test -p infrastructure` Résultat lib (cœur testé) : **188 passed; 0 failed; 0 ignored**. Tous les fichiers d'intégration + doctests également verts. Aucune régression résiduelle. J'ai ajouté **26 tests LS2** (tous via fonctions pures `parse_reset_ms`/`parse_event`, sauf 1 via `send()`+FakeCli — jamais le vrai claude). Tout est dans `crates/infrastructure/src/session/mod.rs` (`#[cfg(test)] mod tests`, là où vivent réellement les tests claude.rs). parse_reset_ms — noms de champ & priorité - `recognises_every_field_name` : resetsAt / resets_at / reset_at / resetAt / reset chacun reconnu - `first_known_key_wins` : resetsAt prime sur reset (1er de l'ordre gagne) parse_reset_ms — heuristique secondes/ms + SEUIL - `integer_seconds_are_scaled_to_ms` (×1000) / `integer_millis_are_kept_as_is` - `threshold_boundary` : 10^12−1 ⇒ secondes (×1000) ; 10^12 pile ⇒ ms (tel quel, borne inclusive côté ms) - `float_seconds_preserve_fraction` (1_700_000_000.5 ⇒ 1_700_000_000_500) / `float_millis_kept_as_is` - `string_integer_*` / `string_float_*` : même heuristique sur chaînes numériques parse_reset_ms — ISO-8601 / RFC3339 (parseur maison) - `iso_utc_z` : "2023-11-14T22:13:20Z" ⇒ 1_700_000_000_000 (recoupé contre l'epoch-secondes connu) - `iso_positive_offset` / `iso_negative_offset` / `iso_compact_offset` (+01:00, −01:00, +0100 = même instant UTC) - `iso_fraction_padded_and_truncated` : .5⇒500, .123456⇒123 (tronqué), .7⇒700 (complété) - robustesse : `unknown_key_yields_none`, `non_numeric_garbage_yields_none` (bool/null/array/objet/chaîne pourrie), `invalid_iso_string_yields_none` days_from_civil & bissextiles - `unix_epoch_is_zero` : 1970-01-01T00:00:00Z ⇒ 0 - `leap_day_2024_02_29` : 2024-02-29T00:00:00Z ⇒ 1_709_164_800_000 (calculé à la main) - `known_post_2001_date` : 2021-01-01T00:00:00Z ⇒ 1_609_459_200_000 parse_event (mapping) + non-terminalité (T4) - `parse_event_rate_limit_with_reset_yields_rate_limited_some` : resetsAt exploitable ⇒ RateLimited{Some} - `parse_event_rate_limit_without_info_is_rate_limited_none_not_heartbeat` : absent / clé inconnue ⇒ RateLimited{None} (et PAS Heartbeat) - `parse_event_sequence_rate_limited_then_final_is_not_interrupted` : [RateLimited, Final] au niveau parse - `send_emits_rate_limited_intercalated_only_final_closes` (FakeCli) : flux complet = [Heartbeat, RateLimited, TextDelta, Final], le RateLimited n'interrompt PAS, un seul Final clôt — prouvé bout-en-bout ⚠️ DEUX TESTS EXISTANTS CASSAIENT au lancement initial (avant mes ajouts) — ce n'est PAS un bug de prod, c'est l'ancien contrat qui survivait : 1. `claude_parse_rate_limit_event_is_heartbeat` (mod.rs) — assertait l'ANCIEN comportement (rate_limit_event ⇒ Heartbeat). Le livrable §21 émet désormais RateLimited. → Je l'ai RENOMMÉ en `claude_parse_rate_limit_event_without_reset_is_rate_limited_none` et mis à jour l'assertion vers `RateLimited{None}`. 2. `claude_session_respects_port_contract` — le harnais de conformité `assert_agent_session_contract` (conformance.rs, module `#[cfg(test)]`) n'autorisait avant le Final que TextDelta/ToolActivity/Heartbeat. Le `claude_script()` contient une ligne rate_limit_event qui produit maintenant un RateLimited intercalé → rejeté. → J'ai ajouté `ReplyEvent::RateLimited { .. }` à la liste des événements non terminaux autorisés (conforme à §21-T4 : « s'intercale exactement comme un Heartbeat »). Ces deux modifs portent UNIQUEMENT sur du code de test (`#[cfg(test)]`), pas sur la prod. Je n'ai touché aucune ligne de `claude.rs`. Le code de prod de DevBackend est conforme au cadrage §21 sur tous les axes testés (noms de champ, priorité, seuil 10^12 exact, floats, offsets ISO signés, fractions ms, bissextile 2024, epoch Unix, robustesse zéro-panic, non-terminalité T4). Feature validée côté tests infra. Prêt pour la suite du cycle. @@ -23,4 +17,10 @@ objective: Tâche : ajouter UN test fonctionnel anti-régression de la communica - **Response:** QA LS7-front — VERT. Aucun bug d'implémentation trouvé. Rien commité. ## Fichiers de test ajoutés (2) - `frontend/src/features/agents/useAgentsLimits.test.tsx` (13 tests) — état `limitByAgent` du hook via le `MockSystemGateway` qui émet les events + `MockInputGateway`. - `frontend/src/features/agents/AgentLimitBadge.test.tsx` (11 tests) — helpers purs + rendu du badge. ## Commandes exactes lancées (cwd = frontend/) 1. `npx vitest run src/features/agents/useAgentsLimits.test.tsx src/features/agents/AgentLimitBadge.test.tsx` → **Test Files 2 passed (2) · Tests 24 passed (24)** 2. `npx vitest run src/features/agents/` (suite agents complète, régression) → **Test Files 4 passed (4) · Tests 63 passed (63)** (39 existants + 24 nouveaux, 0 régression) ## Couverture livrée useAgents (§21), tous via events réels : - `agentRateLimited{resetsAtMs}` → `{limitedUntil, resumeFireAt:undefined, suspected:false}` ✓ - `agentResumeScheduled{fireAtMs}` → arme `resumeFireAt` par-dessus l'état limité ✓ - `agentResumeCancelled` → retire `resumeFireAt`, reste limité ✓ ; + no-op sur agent inconnu (aucune entrée créée) ✓ - `agentResumed` → entrée supprimée (`agentId in map === false`) ✓ - `agentRateLimitSuspected` AVEC et SANS `resetsAtMs` → `suspected:true`, `limitedUntil` undefined dans le cas sans heure ✓ - séquence réaliste rateLimited→scheduled→`cancelResume(action)` : retrait optimiste du countdown + agent toujours limité + `input.cancelledResumes === [agentId]` + verdict ✓ - action `cancelResume` : verdict `true` ET `false` (via `cancelResumeResult`), + appel du port même sans reprise armée ✓ - deux agents foldés indépendamment ✓ AgentLimitBadge : - `formatCountdown` : 0→"0s", négatif→"0s" (clamp), <60s→"Xs", ceil partiel (4.2s→"5s"), ≥60s→"Xm Ys" (60000→"1m 0s", 125000→"2m 5s") ✓ - `formatResetTime` : HH:MM sans secondes (même minute +30s = même libellé), minuit stable ✓ - rendu : "limité jusqu'à HH:MM" (heure connue) / "limité" (sans heure) / note "heure inconnue — reprise à préciser" (suspected sans heure) + absence de la note quand suspected A une heure ✓ - bouton "Annuler la reprise" : appelle `onCancelResume` au clic quand `resumeFireAt` armé, countdown rendu, bouton désactivé si `busy` ✓ MockInputGateway.cancelResume (`cancelledResumes`/`cancelResumeResult`) : couvert via les tests du hook. ## Note hors-périmètre (PRÉEXISTANTE, pas causée par LS7-front) `src/adapters/mock/mock.test.ts:16` échoue (1 test) : il assert « thirteen gateways » mais le `permission` gateway porte le compte à 14. Vérifié : la gateway `permission` existe déjà dans HEAD committé (eca2ba9) — l'assertion de comptage est simplement périmée, sans rapport avec les limites de session. Mes deux fichiers ne touchent pas ce test. Aucune action de ma part (hors scope) ; à relayer pour une mise à jour du compteur si tu veux la suite `adapters/mock` 100% verte. - **Prompt:** Tâche QA — tests unitaires LS8-backend (filet humain niveau 3), branche `feature/agent-session-limits`. DevBackend a implémenté (build 0 erreur/0 warning, suites domain/application/app-tauri déjà vertes, non commité). À COUVRIR — nouvelle méthode `SessionLimitService::confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` dans `crates/application/src/agent/session_limit.rs`. Teste via les fakes existants (Clock/Scheduler/EventBus) du fichier `crates/application/tests/session_limit_service.rs` (suis ce style). Cas (cadrage Architect) : (a) heure FUTURE ⇒ publie `AgentRateLimited{Some(resets_at_ms)}` puis `AgentResumeScheduled{fire_at_ms == resets_at_ms}` + un ScheduleId armé sur le scheduler. (b) heure PASSÉE (resets_at_ms < now) ⇒ clamp : `fire_at_ms == now` (reprise quasi-immédiate). Vérifie l'événement `AgentResumeScheduled` avec fire_at_ms == now. (c) DÉDOUBLONNAGE croisé : `confirm_human_resume` APRÈS un `on_rate_limited` déjà armé pour le même agent ⇒ le précédent réveil est désarmé, une seule entrée armée (et l'inverse : `on_rate_limited` après `confirm_human_resume`). Vérifie qu'il n'y a qu'un seul armement actif. (d) ANNULABILITÉ : `cancel_resume(agent_id)` après `confirm_human_resume` ⇒ publie `AgentResumeCancelled` et renvoie true (l'armement humain s'annule par la même voie). (e) PARITÉ auto/humain : même séquence d'événements qu'`on_rate_limited` cas Scheduled (à reset identique). À COUVRIR — commande `set_resume_at` (app-tauri) si testable au niveau intégration comme `session_limit_wiring.rs` : au minimum le cas `node_for_agent == None` ⇒ erreur NOT_FOUND (pas d'armement orphelin). Si le câblage rend ce test trop lourd (besoin d'une cellule vivante), note-le et concentre-toi sur la couverture application (a–e) qui est l'essentiel. Exécute (`cargo test -p application` + `-p app-tauri` si pertinent). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), rapport d'erreurs complet si rouge. Si un test révèle un bug (notamment sur le dédoublonnage croisé ou le clamp passé), décris-le précisément (attendu vs obtenu) — je relaierai à DevBackend. Ne commit rien. - **Response:** QA LS8-backend (filet humain niveau 3) — VERT. Aucun bug d'implémentation trouvé (ni sur le dédoublonnage croisé, ni sur le clamp passé). Rien commité. ## Fichiers de test modifiés (2 — ajouts only, fakes existants réutilisés) - `crates/application/tests/session_limit_service.rs` : +6 tests `confirm_human_resume` (style des fakes Clock/Scheduler/EventBus existants). - `crates/app-tauri/tests/session_limit_wiring.rs` : +2 tests (précondition NOT_FOUND + parité runtime humaine sur le vrai bus). ## Commandes exactes lancées + résultats réels 1. `cargo test -p application --test session_limit_service` → **15 passed; 0 failed** (10 existants + 5 nouveaux). 2. `cargo test -p app-tauri --test session_limit_wiring` → **4 passed; 0 failed** (2 existants + 2 nouveaux). 3. `cargo test -p application` (régression complète) → **toutes suites vertes, 0 failed**. 4. `cargo test -p app-tauri` (régression complète) → **toutes suites vertes, 0 failed**. ## Couverture application (a–e) — l'essentiel - **(a) heure FUTURE** `confirm_human_resume_future_arms_and_emits_in_order` : exactement 1 `arm(fire_at_ms==resets_at_ms, ResumeAgent{agent,node,conv})`, 1 ScheduleId émis, events `AgentRateLimited{Some(reset)}` PUIS `AgentResumeScheduled{fire_at_ms==reset}` dans cet ordre. ✓ - **(b) heure PASSÉE** `confirm_human_resume_past_reset_clamps_fire_at_to_now` : `fire_at_ms == now` (clamp anti-passé) ; `AgentRateLimited` garde l'heure brute passée, `AgentResumeScheduled` porte le `now` clampé. ✓ - **(c) dédoublonnage CROISÉ** (les deux sens) : - `confirm_human_resume_after_auto_dedups_single_active_arm` : confirm APRÈS on_rate_limited → l'ancien ScheduleId auto est cancel-é (`cancels == [issued[0]]`), pas d'`AgentResumeCancelled` (silencieux). Unicité prouvée : 1er `cancel_resume`→true, 2e→false. ✓ - `auto_after_confirm_human_resume_dedups_single_active_arm` : on_rate_limited APRÈS confirm → symétrique, même invariant d'unicité. ✓ - **(d) ANNULABILITÉ** `cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled` : renvoie `true` + publie `AgentResumeCancelled` (même voie que l'auto). ✓ - **(e) PARITÉ auto/humain** `confirm_human_resume_is_event_for_event_identical_to_auto_scheduled` : à reset futur identique, `bus.events()` ET `scheduler.armed()` strictement égaux entre `confirm_human_resume` et `on_rate_limited`. La source Human vs Structured n'a aucun effet observable. ✓ ## Couverture app-tauri `set_resume_at` - **NOT_FOUND** `set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session` : ✓ couvert au niveau précondition. NOTE : la commande `#[tauri::command] set_resume_at` exige `State`, non constructible hors runtime Tauri — le harnais wiring existant n'invoque jamais les commandes, seulement le service. J'ai donc testé la **précondition exacte** qui produit le NOT_FOUND : sur un `AppState::build` neuf, `structured_sessions.node_for_agent(unknown)` ET `terminal_sessions.node_for_agent(unknown)` renvoient `None` → la branche `ok_or_else(NotFound)` (commands.rs:1420-1428) est prise → aucun armement orphelin. C'est la couverture maximale réaliste sans faire tourner Tauri. - **Parité runtime** `confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus` : sur le vrai `TokioBroadcastEventBus`, `confirm_human_resume` publie `AgentRateLimited` puis `AgentResumeScheduled` et l'armement est annulable (`cancel_resume`→true), exactement comme la branche auto déjà testée. ✓ ## Observation (non bloquante, pas un bug) `confirm_human_resume` est total et défensif : le cas `ResumePlan::HumanFallback` y est inatteignable (`resets_at_ms` toujours `Some`) → traité en no-op via `if let Scheduled`. Conforme au cadrage Architect ; pas de test dédié (branche morte par construction). Build app-tauri OK (0 erreur). Rien n'a été commité. -- **Prompt:** Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug. Points à vérifier/exécuter : 1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin. 2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel. 3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`. 4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît. 5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts. Exécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien. \ No newline at end of file +- **Prompt:** Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug. Points à vérifier/exécuter : 1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin. 2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel. 3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`. 4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît. 5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts. Exécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien. +- **Prompt:** QA rapide correctif IdeA Codex structured runtime: j'ai ajouté `PreparedContext.project_root`, relayé ce champ depuis `LaunchAgent`, puis `StructuredSessionFactory` passe `vec![ctx.project_root]` à `CodexExecSession`. `CodexExecSession` ajoute maintenant `--add-dir ` après `--sandbox workspace-write` et avant le prompt, sans `--ask-for-approval`. Tests ciblés verts: `cargo test -p infrastructure codex_new_conversation_command_carries_exact_args`, `cargo test -p infrastructure codex_resume_command_carries_exact_args`; `cargo check --workspace` vert avec warning préexistant `may_write_directly`. Peux-tu relire le risque principal et indiquer s'il manque un test critique avant rebuild AppImage ? +- **Response:** QA rapide terminée. Verdict : le risque principal est bien le **pont entre le run dir isolé et le vrai project root** pour `codex exec` : si `--add-dir ` manque, est mal ordonné, ou si `--ask-for-approval` revient, Codex peut soit rester confiné au run dir, soit échouer au démarrage avec l’argument inconnu. Les deux tests ciblés protègent correctement la composition argv de `CodexExecSession` en neuf et en reprise : ordre exact, `--sandbox workspace-write`, `--add-dir /project/root` avant le prompt, et absence de `--ask-for-approval`. Relecture du câblage : - `PreparedContext` porte maintenant `project_root: String` dans `domain::ports`. - `LaunchAgent` le remplit avec `input.project.root.as_str().to_owned()`. - `StructuredSessionFactory` passe `vec![ctx.project_root.clone()]` uniquement au bras `StructuredAdapter::Codex`. - `CodexExecSession::build_spawn_line` ajoute `--add-dir ` après `--sandbox workspace-write`, filtre les racines vides, puis pousse le prompt en dernier. Test critique manquant ? Pas bloquant pour rebuild AppImage selon moi, parce que le bug runtime immédiat est verrouillé au niveau où l’argv réel est construit. Le seul test de renfort que j’ajouterais si on veut fermer complètement la régression est un test **factory-level** : démarrer un profil Codex via `StructuredSessionFactory::start(...)` avec un `PreparedContext { project_root: "/project/root" }`, fake CLI enregistreur, `send()`, puis asserter que l’argv contient bien `--add-dir /project/root`. Les tests actuels instancient `CodexExecSession` directement, donc ils ne prouvent pas explicitement que la factory relaie toujours `ctx.project_root`. Ce n’est pas un risque suffisant pour bloquer le rebuild, mais c’est le garde-fou le plus utile à ajouter ensuite. Sortie réelle relancée : `cargo test -p infrastructure codex_new_conversation_command_carries_exact_args` : ```text running 1 test test session::tests::codex_new_conversation_command_carries_exact_args ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s ``` `cargo test -p infrastructure codex_resume_command_carries_exact_args` : ```text running 1 test test session::tests::codex_resume_command_carries_exact_args ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s ``` Warning observé, conforme à ton signalement : `unused import: may_write_directly` dans `crates/application/src/orchestrator/context_guard.rs:29:25`. +- **Prompt:** Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Crée un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, supprime-le immédiatement si la création réussit, puis rapporte le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée. +- **Response:** Commande exécutée (sans git add/commit ni action Git destructive) : création d’un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, suppression prévue uniquement en cas de succès. Résultat : ```text touch: cannot touch '/home/anthony/Documents/Projects/IdeA/.git/idea-write-test-2-1781903741512743602': Read-only file system ``` Conclusion : écriture refusée par le noyau avec `Read-only file system`. Aucun fichier temporaire n’a été créé, donc aucune suppression n’était nécessaire. +- **Prompt:** Validation QA du chantier `orchestrator-designation` sur la branche courante `feature/orchestrator-designation`. Contexte cycle: - Git a décidé de finir ce chantier sur la branche actuelle, sans switch/stash. - DevBackend vient de corriger `crates/application/src/orchestrator/context_guard.rs`. - Main ne code pas; tu dois valider/qualifier. Historique des validations: Avant correction DevBackend: - `cargo test -p infrastructure input --lib`: OK, 35 passed. - `cargo test -p application --test orchestrator_service`: OK, 45 passed. - `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed. - `cd frontend && npx tsc --noEmit`: OK. - `cargo test --workspace`: échouait à la compilation sur `context_guard.rs` (`may_write_directly` 3e arg manquant, `AgentManifest.orchestrator` mal initialisé). Correction DevBackend: - `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`. - Tests locaux `context_guard.rs` adaptés au modèle `AgentManifest { version, entries, orchestrator }`. - Le `FileGuard` reste un verrou; l'autorisation orchestrateur reste côté domaine. Validations DevBackend après correction: 1. `cargo fmt --all && cargo test -p application --test orchestrator_service` - OK, `45 passed; 0 failed; 0 ignored`. 2. `cargo test -p application` - OK, suite application verte. 3. `cargo test --workspace` - Compile maintenant plus loin, puis échoue dans `app-tauri`: - `app-tauri` lib: `39 passed; 8 failed`. - Échecs: `mcp_bridge::tests::end_to_end_over_real_loopback`, `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, et `state::mcp_e2e_loopback_tests::*`. - Cause observée: `PermissionDenied` / `Operation not permitted` lors du bind socket sous `/run/user/1000/idea-mcp/*.sock`, ex. `bind test listener: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }`. Ta mission QA: 1. Relance les tests pertinents si possible. 2. Qualifie le résidu `app-tauri`: régression code ou contrainte environnement/sandbox ? 3. Donne à Main un verdict clair: chantier validable ou correction requise. 4. Si correction requise, donne le rapport d'erreur exact pour DevBackend. 5. Si validable sous contrainte, donne la liste de commandes vertes qui font foi et la réserve exacte. Réponds via `idea_reply` si cette tâche arrive via IdeA. +- **Response:** Verdict QA: chantier `orchestrator-designation` validable sous contrainte d'environnement. Je ne vois pas de régression fonctionnelle liée à la correction `context_guard.rs`. Commandes vertes qui font foi: - `cargo fmt --all -- --check`: OK, aucune sortie. - `cargo test -p application --test orchestrator_service`: OK, `45 passed; 0 failed; 0 ignored`. - `cargo test -p application`: OK, suite application complète verte; inclut `orchestrator::context_guard::tests::orchestrator_writes_global_context_directly` et `agent_proposing_global_context_files_a_proposal_not_a_write`. - `cargo test -p infrastructure input --lib`: OK, `35 passed; 0 failed; 0 ignored; 187 filtered out`. - `cd frontend && npx vitest run`: OK, `41 passed (41)` files, `384 passed (384)` tests. - `cd frontend && npx tsc --noEmit`: OK, aucune sortie. Résidu `app-tauri`: - `cargo test -p app-tauri --lib`: ROUGE, `39 passed; 8 failed; 0 ignored`. - `cargo test --workspace`: ROUGE sur le même bloc `app-tauri`; la compilation va désormais plus loin et ne bloque plus sur `context_guard.rs`. Échecs exacts observés: - `mcp_bridge::tests::end_to_end_over_real_loopback`, [crates/app-tauri/src/mcp_bridge.rs:577] : `bind test listener: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }`. - `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, [crates/app-tauri/src/state.rs:4787] : `lay corpse socket: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }`. - `state::mcp_e2e_loopback_tests::{handshake_requester_propagates_over_real_loopback,list_agents_round_trips_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback_codex,malformed_jsonrpc_after_handshake_errors_and_server_survives,orphan_reply_is_typed_error_over_real_loopback}`, [crates/app-tauri/src/state.rs:4208] : `M5e needs a real bound listener; bind_endpoint returned None for "/run/user/1000/idea-mcp/.sock"`. Qualification du résidu: - Contrainte environnement/sandbox, pas régression du chantier. Une sonde Node minimale dans ce même environnement échoue à créer un listener Unix avec `EPERM` même hors `/run/user/1000`: - `/run/user/1000/idea-mcp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`. - `/tmp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`. - Les 8 tests rouges sont précisément ceux qui exigent un vrai loopback Unix; les tests `mcp_serve_peer_tests` duplex/in-memory et les tests de wiring/domain restent verts. Conclusion: correction DevBackend suffisante pour ce chantier. Pas de correction requise côté `orchestrator-designation`; réserve exacte: la validation globale `cargo test --workspace` reste impossible dans ce sandbox tant que les tests `app-tauri` nécessitant `listen()` sur socket Unix ne peuvent pas binder. \ No newline at end of file diff --git a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl index 7f93697..509e383 100644 --- a/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl +++ b/.ideai/conversations/08336578-5b47-09d2-2f41-5cc483f101f4/log.jsonl @@ -50,3 +50,9 @@ {"id":"2acee2f9-cf46-4f65-8131-8a66751c55e0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676775264,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — tests unitaires LS8-backend (filet humain niveau 3), branche `feature/agent-session-limits`. DevBackend a implémenté (build 0 erreur/0 warning, suites domain/application/app-tauri déjà vertes, non commité).\n\nÀ COUVRIR — nouvelle méthode `SessionLimitService::confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` dans `crates/application/src/agent/session_limit.rs`. Teste via les fakes existants (Clock/Scheduler/EventBus) du fichier `crates/application/tests/session_limit_service.rs` (suis ce style). Cas (cadrage Architect) :\n(a) heure FUTURE ⇒ publie `AgentRateLimited{Some(resets_at_ms)}` puis `AgentResumeScheduled{fire_at_ms == resets_at_ms}` + un ScheduleId armé sur le scheduler.\n(b) heure PASSÉE (resets_at_ms < now) ⇒ clamp : `fire_at_ms == now` (reprise quasi-immédiate). Vérifie l'événement `AgentResumeScheduled` avec fire_at_ms == now.\n(c) DÉDOUBLONNAGE croisé : `confirm_human_resume` APRÈS un `on_rate_limited` déjà armé pour le même agent ⇒ le précédent réveil est désarmé, une seule entrée armée (et l'inverse : `on_rate_limited` après `confirm_human_resume`). Vérifie qu'il n'y a qu'un seul armement actif.\n(d) ANNULABILITÉ : `cancel_resume(agent_id)` après `confirm_human_resume` ⇒ publie `AgentResumeCancelled` et renvoie true (l'armement humain s'annule par la même voie).\n(e) PARITÉ auto/humain : même séquence d'événements qu'`on_rate_limited` cas Scheduled (à reset identique).\n\nÀ COUVRIR — commande `set_resume_at` (app-tauri) si testable au niveau intégration comme `session_limit_wiring.rs` : au minimum le cas `node_for_agent == None` ⇒ erreur NOT_FOUND (pas d'armement orphelin). Si le câblage rend ce test trop lourd (besoin d'une cellule vivante), note-le et concentre-toi sur la couverture application (a–e) qui est l'essentiel.\n\nExécute (`cargo test -p application` + `-p app-tauri` si pertinent). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), rapport d'erreurs complet si rouge. Si un test révèle un bug (notamment sur le dédoublonnage croisé ou le clamp passé), décris-le précisément (attendu vs obtenu) — je relaierai à DevBackend. Ne commit rien."} {"id":"a2c0999e-227a-4cfb-99f5-2e8b80ce84f0","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781676917946,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA LS8-backend (filet humain niveau 3) — VERT. Aucun bug d'implémentation trouvé (ni sur le dédoublonnage croisé, ni sur le clamp passé). Rien commité.\n\n## Fichiers de test modifiés (2 — ajouts only, fakes existants réutilisés)\n- `crates/application/tests/session_limit_service.rs` : +6 tests `confirm_human_resume` (style des fakes Clock/Scheduler/EventBus existants).\n- `crates/app-tauri/tests/session_limit_wiring.rs` : +2 tests (précondition NOT_FOUND + parité runtime humaine sur le vrai bus).\n\n## Commandes exactes lancées + résultats réels\n1. `cargo test -p application --test session_limit_service`\n → **15 passed; 0 failed** (10 existants + 5 nouveaux).\n2. `cargo test -p app-tauri --test session_limit_wiring`\n → **4 passed; 0 failed** (2 existants + 2 nouveaux).\n3. `cargo test -p application` (régression complète) → **toutes suites vertes, 0 failed**.\n4. `cargo test -p app-tauri` (régression complète) → **toutes suites vertes, 0 failed**.\n\n## Couverture application (a–e) — l'essentiel\n- **(a) heure FUTURE** `confirm_human_resume_future_arms_and_emits_in_order` : exactement 1 `arm(fire_at_ms==resets_at_ms, ResumeAgent{agent,node,conv})`, 1 ScheduleId émis, events `AgentRateLimited{Some(reset)}` PUIS `AgentResumeScheduled{fire_at_ms==reset}` dans cet ordre. ✓\n- **(b) heure PASSÉE** `confirm_human_resume_past_reset_clamps_fire_at_to_now` : `fire_at_ms == now` (clamp anti-passé) ; `AgentRateLimited` garde l'heure brute passée, `AgentResumeScheduled` porte le `now` clampé. ✓\n- **(c) dédoublonnage CROISÉ** (les deux sens) :\n - `confirm_human_resume_after_auto_dedups_single_active_arm` : confirm APRÈS on_rate_limited → l'ancien ScheduleId auto est cancel-é (`cancels == [issued[0]]`), pas d'`AgentResumeCancelled` (silencieux). Unicité prouvée : 1er `cancel_resume`→true, 2e→false. ✓\n - `auto_after_confirm_human_resume_dedups_single_active_arm` : on_rate_limited APRÈS confirm → symétrique, même invariant d'unicité. ✓\n- **(d) ANNULABILITÉ** `cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled` : renvoie `true` + publie `AgentResumeCancelled` (même voie que l'auto). ✓\n- **(e) PARITÉ auto/humain** `confirm_human_resume_is_event_for_event_identical_to_auto_scheduled` : à reset futur identique, `bus.events()` ET `scheduler.armed()` strictement égaux entre `confirm_human_resume` et `on_rate_limited`. La source Human vs Structured n'a aucun effet observable. ✓\n\n## Couverture app-tauri `set_resume_at`\n- **NOT_FOUND** `set_resume_at_resolves_no_cell_for_an_agent_without_a_live_session` : ✓ couvert au niveau précondition. NOTE : la commande `#[tauri::command] set_resume_at` exige `State`, non constructible hors runtime Tauri — le harnais wiring existant n'invoque jamais les commandes, seulement le service. J'ai donc testé la **précondition exacte** qui produit le NOT_FOUND : sur un `AppState::build` neuf, `structured_sessions.node_for_agent(unknown)` ET `terminal_sessions.node_for_agent(unknown)` renvoient `None` → la branche `ok_or_else(NotFound)` (commands.rs:1420-1428) est prise → aucun armement orphelin. C'est la couverture maximale réaliste sans faire tourner Tauri.\n- **Parité runtime** `confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus` : sur le vrai `TokioBroadcastEventBus`, `confirm_human_resume` publie `AgentRateLimited` puis `AgentResumeScheduled` et l'armement est annulable (`cancel_resume`→true), exactement comme la branche auto déjà testée. ✓\n\n## Observation (non bloquante, pas un bug)\n`confirm_human_resume` est total et défensif : le cas `ResumePlan::HumanFallback` y est inatteignable (`resets_at_ms` toujours `Some`) → traité en no-op via `if let Scheduled`. Conforme au cadrage Architect ; pas de test dédié (branche morte par construction).\n\nBuild app-tauri OK (0 erreur). Rien n'a été commité."} {"id":"7c627a21-5a9f-470e-9137-a63697ae87da","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781677172091,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche QA — validation LS8-front (formulaire de saisie d'heure, filet humain niveau 3) sur `feature/agent-session-limits`. DevFrontend a implémenté ET ajusté/ajouté des tests (typecheck propre, `npx vitest run src/features/agents/ src/adapters/mock/` = 109 tests verts). Ton rôle : valider la couverture, exécuter, et signaler tout trou ou bug.\n\nPoints à vérifier/exécuter :\n1. Helper pur `timeInputToEpochMs(value, now)` (AgentLimitBadge.tsx) : cas couverts ? « HH:MM » valide → aujourd'hui à cette heure ; instant passé dans la journée → epoch < now (le backend clampe, pas le front) ; malformé/vide/`25:00`/`12:60` → `null`. Ajoute les cas manquants si besoin.\n2. Soumission du formulaire (état suspected SANS heure) ⇒ `onSetResumeAt` / action hook `setResumeAt` appelée avec l'epoch attendu ; bouton désactivé si saisie vide ou `busy` ; saisie malformée (`null`) ne déclenche PAS d'appel.\n3. Action hook `useAgents.setResumeAt(agentId, resetsAtMs)` : délègue bien au port `input.setResumeAt`, aucune mutation optimiste (vérifie que l'état ne bascule QUE sur réception de `agentResumeScheduled`, pas avant). Mock instrumenté `resumeArmings`.\n4. Bascule complète : agent suspected sans heure → submit form → émettre `agentResumeScheduled` (via mock) → le badge passe à l'état nominal (countdown + bouton Annuler), le formulaire disparaît.\n5. Non-régression : tout `src/features/agents/` + `src/adapters/mock/` vert. Confirme le compteur gateways corrigé (14) dans mock.test.ts.\n\nExécute `npx vitest run src/features/agents/ src/adapters/mock/` (+ `tsc --noEmit` si tu veux). RENDS-MOI via idea_reply : commande exacte, résultat réel (passés/échoués), trous comblés (fichiers/tests ajoutés), et tout bug trouvé (attendu vs obtenu, fichier:ligne) — je relaierai à DevFrontend. Ne commit rien."} +{"id":"414e8769-7a3c-4617-b391-f97bc5490877","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781886707511,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"QA rapide correctif IdeA Codex structured runtime: j'ai ajouté `PreparedContext.project_root`, relayé ce champ depuis `LaunchAgent`, puis `StructuredSessionFactory` passe `vec![ctx.project_root]` à `CodexExecSession`. `CodexExecSession` ajoute maintenant `--add-dir ` après `--sandbox workspace-write` et avant le prompt, sans `--ask-for-approval`. Tests ciblés verts: `cargo test -p infrastructure codex_new_conversation_command_carries_exact_args`, `cargo test -p infrastructure codex_resume_command_carries_exact_args`; `cargo check --workspace` vert avec warning préexistant `may_write_directly`. Peux-tu relire le risque principal et indiquer s'il manque un test critique avant rebuild AppImage ?"} +{"id":"0ba164a2-0479-442a-a992-3a6e519a825d","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781886791342,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"QA rapide terminée.\n\nVerdict : le risque principal est bien le **pont entre le run dir isolé et le vrai project root** pour `codex exec` : si `--add-dir ` manque, est mal ordonné, ou si `--ask-for-approval` revient, Codex peut soit rester confiné au run dir, soit échouer au démarrage avec l’argument inconnu. Les deux tests ciblés protègent correctement la composition argv de `CodexExecSession` en neuf et en reprise : ordre exact, `--sandbox workspace-write`, `--add-dir /project/root` avant le prompt, et absence de `--ask-for-approval`.\n\nRelecture du câblage :\n- `PreparedContext` porte maintenant `project_root: String` dans `domain::ports`.\n- `LaunchAgent` le remplit avec `input.project.root.as_str().to_owned()`.\n- `StructuredSessionFactory` passe `vec![ctx.project_root.clone()]` uniquement au bras `StructuredAdapter::Codex`.\n- `CodexExecSession::build_spawn_line` ajoute `--add-dir ` après `--sandbox workspace-write`, filtre les racines vides, puis pousse le prompt en dernier.\n\nTest critique manquant ? Pas bloquant pour rebuild AppImage selon moi, parce que le bug runtime immédiat est verrouillé au niveau où l’argv réel est construit. Le seul test de renfort que j’ajouterais si on veut fermer complètement la régression est un test **factory-level** : démarrer un profil Codex via `StructuredSessionFactory::start(...)` avec un `PreparedContext { project_root: \"/project/root\" }`, fake CLI enregistreur, `send()`, puis asserter que l’argv contient bien `--add-dir /project/root`. Les tests actuels instancient `CodexExecSession` directement, donc ils ne prouvent pas explicitement que la factory relaie toujours `ctx.project_root`. Ce n’est pas un risque suffisant pour bloquer le rebuild, mais c’est le garde-fou le plus utile à ajouter ensuite.\n\nSortie réelle relancée :\n\n`cargo test -p infrastructure codex_new_conversation_command_carries_exact_args` :\n```text\nrunning 1 test\ntest session::tests::codex_new_conversation_command_carries_exact_args ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s\n```\n\n`cargo test -p infrastructure codex_resume_command_carries_exact_args` :\n```text\nrunning 1 test\ntest session::tests::codex_resume_command_carries_exact_args ... ok\n\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 220 filtered out; finished in 0.00s\n```\n\nWarning observé, conforme à ton signalement : `unused import: may_write_directly` dans `crates/application/src/orchestrator/context_guard.rs:29:25`."} +{"id":"6ec1451a-fe4d-4e8d-be02-52425f1f8f88","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781903727049,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Crée un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, supprime-le immédiatement si la création réussit, puis rapporte le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée."} +{"id":"ccb44f24-80cb-45ee-a724-60313202ae96","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781903746184,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"Commande exécutée (sans git add/commit ni action Git destructive) : création d’un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, suppression prévue uniquement en cas de succès.\n\nRésultat :\n```text\ntouch: cannot touch '/home/anthony/Documents/Projects/IdeA/.git/idea-write-test-2-1781903741512743602': Read-only file system\n```\n\nConclusion : écriture refusée par le noyau avec `Read-only file system`. Aucun fichier temporaire n’a été créé, donc aucune suppression n’était nécessaire."} +{"id":"8bc3c570-3204-444d-ace5-ada62913f082","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781938370874,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Validation QA du chantier `orchestrator-designation` sur la branche courante `feature/orchestrator-designation`.\n\nContexte cycle:\n- Git a décidé de finir ce chantier sur la branche actuelle, sans switch/stash.\n- DevBackend vient de corriger `crates/application/src/orchestrator/context_guard.rs`.\n- Main ne code pas; tu dois valider/qualifier.\n\nHistorique des validations:\nAvant correction DevBackend:\n- `cargo test -p infrastructure input --lib`: OK, 35 passed.\n- `cargo test -p application --test orchestrator_service`: OK, 45 passed.\n- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.\n- `cd frontend && npx tsc --noEmit`: OK.\n- `cargo test --workspace`: échouait à la compilation sur `context_guard.rs` (`may_write_directly` 3e arg manquant, `AgentManifest.orchestrator` mal initialisé).\n\nCorrection DevBackend:\n- `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`.\n- Tests locaux `context_guard.rs` adaptés au modèle `AgentManifest { version, entries, orchestrator }`.\n- Le `FileGuard` reste un verrou; l'autorisation orchestrateur reste côté domaine.\n\nValidations DevBackend après correction:\n1. `cargo fmt --all && cargo test -p application --test orchestrator_service`\n - OK, `45 passed; 0 failed; 0 ignored`.\n2. `cargo test -p application`\n - OK, suite application verte.\n3. `cargo test --workspace`\n - Compile maintenant plus loin, puis échoue dans `app-tauri`:\n - `app-tauri` lib: `39 passed; 8 failed`.\n - Échecs: `mcp_bridge::tests::end_to_end_over_real_loopback`, `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, et `state::mcp_e2e_loopback_tests::*`.\n - Cause observée: `PermissionDenied` / `Operation not permitted` lors du bind socket sous `/run/user/1000/idea-mcp/*.sock`, ex. `bind test listener: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n\nTa mission QA:\n1. Relance les tests pertinents si possible.\n2. Qualifie le résidu `app-tauri`: régression code ou contrainte environnement/sandbox ?\n3. Donne à Main un verdict clair: chantier validable ou correction requise.\n4. Si correction requise, donne le rapport d'erreur exact pour DevBackend.\n5. Si validable sous contrainte, donne la liste de commandes vertes qui font foi et la réserve exacte.\n\nRéponds via `idea_reply` si cette tâche arrive via IdeA."} +{"id":"abed1f56-1094-4d4d-b058-acc2dcd94ca3","conversation":"08336578-5b47-09d2-2f41-5cc483f101f4","atMs":1781938482886,"source":{"kind":"agent","agentId":"aefdbd61-e3d4-4bc1-9f42-c259446a97b5"},"role":"response","text":"Verdict QA: chantier `orchestrator-designation` validable sous contrainte d'environnement. Je ne vois pas de régression fonctionnelle liée à la correction `context_guard.rs`.\n\nCommandes vertes qui font foi:\n- `cargo fmt --all -- --check`: OK, aucune sortie.\n- `cargo test -p application --test orchestrator_service`: OK, `45 passed; 0 failed; 0 ignored`.\n- `cargo test -p application`: OK, suite application complète verte; inclut `orchestrator::context_guard::tests::orchestrator_writes_global_context_directly` et `agent_proposing_global_context_files_a_proposal_not_a_write`.\n- `cargo test -p infrastructure input --lib`: OK, `35 passed; 0 failed; 0 ignored; 187 filtered out`.\n- `cd frontend && npx vitest run`: OK, `41 passed (41)` files, `384 passed (384)` tests.\n- `cd frontend && npx tsc --noEmit`: OK, aucune sortie.\n\nRésidu `app-tauri`:\n- `cargo test -p app-tauri --lib`: ROUGE, `39 passed; 8 failed; 0 ignored`.\n- `cargo test --workspace`: ROUGE sur le même bloc `app-tauri`; la compilation va désormais plus loin et ne bloque plus sur `context_guard.rs`.\n\nÉchecs exacts observés:\n- `mcp_bridge::tests::end_to_end_over_real_loopback`, [crates/app-tauri/src/mcp_bridge.rs:577] : `bind test listener: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n- `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, [crates/app-tauri/src/state.rs:4787] : `lay corpse socket: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n- `state::mcp_e2e_loopback_tests::{handshake_requester_propagates_over_real_loopback,list_agents_round_trips_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback,ask_then_reply_round_trips_inline_over_real_loopback_codex,malformed_jsonrpc_after_handshake_errors_and_server_survives,orphan_reply_is_typed_error_over_real_loopback}`, [crates/app-tauri/src/state.rs:4208] : `M5e needs a real bound listener; bind_endpoint returned None for \"/run/user/1000/idea-mcp/.sock\"`.\n\nQualification du résidu:\n- Contrainte environnement/sandbox, pas régression du chantier. Une sonde Node minimale dans ce même environnement échoue à créer un listener Unix avec `EPERM` même hors `/run/user/1000`:\n - `/run/user/1000/idea-mcp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`.\n - `/tmp/codex-bind-probe.sock` => `EPERM: listen EPERM: operation not permitted`.\n- Les 8 tests rouges sont précisément ceux qui exigent un vrai loopback Unix; les tests `mcp_serve_peer_tests` duplex/in-memory et les tests de wiring/domain restent verts.\n\nConclusion: correction DevBackend suffisante pour ce chantier. Pas de correction requise côté `orchestrator-designation`; réserve exacte: la validation globale `cargo test --workspace` reste impossible dans ce sandbox tant que les tests `app-tauri` nécessitant `listen()` sur socket Unix ne peuvent pas binder."} diff --git a/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md b/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md index 07176e0..eb6c749 100644 --- a/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md +++ b/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/handoff.md @@ -1,15 +1,9 @@ --- -upTo: 6df9f7b7-fcc4-4b87-94db-33dce2d76f13 +upTo: 7f294345-a6f4-494a-aba5-7343fbbdff3f objective: Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est --- **Objectif :** Nouvelle feature cadrée par Architect, prête à démarrer le développement : « Gestion des limites de session des agents » (détection hiérarchique des limites de session + reprise auto annulable). C'est -- **Response:** Décision (agent Git) — branche retenue : **`feature/agent-session-limits`**, créée **depuis `develop`**. **Raisonnement :** - Nouvelle feature indépendante (LS1→LS8) → conformément au modèle `main ← develop ← feature/*`, elle se développe sur sa propre branche `feature/*`. - Pas besoin de (re)créer `develop` : il existe déjà et est à jour (post-release 0.1.0, `git diff main develop` vide → contenu identique). C'est donc la bonne base de départ. - Nom dérivé du sujet, kebab-case : `feature/agent-session-limits`. **État git après manœuvre (LOCAL, aucun push) :** - Branche courante : `feature/agent-session-limits` @ `401c18a` (= base `develop`). - `develop` @ `401c18a` (inchangé) ← `main` @ `9a8a89f` (tag `v0.1.0`, inchangé). - `feature/agent-skills` @ `ef101db` (autre feature, intacte). **Note :** le working tree porte déjà des changements non commités hérités de l'état courant — notamment `ARCHITECTURE.md` (cadrage Architect) et un nouveau `.ideai/memory/session-limit-handling-design.md`, plus du runtime `.ideai/` (conversations/MEMORY.md). Ils n'ont pas été commités : DevBackend/QA les intégreront dans les commits de la feature au fil des lots. Le cadrage Architect (ARCHITECTURE.md + memory design) pourra faire le **premier commit** de la branche avant LS1 si tu le souhaites — dis-moi si tu veux que je le commite maintenant. Dev peut démarrer LS1 (domaine) sur `feature/agent-session-limits`. Après chaque lot vert, reviens vers moi pour décider d'un éventuel commit/merge. Aucune intégration vers `develop` tant que les tests des lots concernés ne sont pas verts (règle d'or). -- **Prompt:** Feu vert pour le premier commit de la branche feature/agent-session-limits : commite le cadrage AVANT que le dev (LS1) ne démarre, pour isoler proprement le cadrage du code. Contenu à committer (cadrage uniquement) : ARCHITECTURE.md (nouvelle §21) + .ideai/memory/session-limit-handling-design.md + la mise à jour de .ideai/memory/MEMORY.md (pointeur). N'inclus PAS le runtime .ideai/ non pertinent (conversations, layouts, agents.json…) s'il pollue le diff — limite le commit aux fichiers du cadrage. Message de commit conforme au style du dépôt (français, conventionnel). Périmètre LOCAL uniquement — aucun push. Confirme le hash du commit et l'état git après. -- **Response:** Cadrage committé sur `feature/agent-session-limits` — premier commit de la branche, isolé du code. **Commit : `fa5b826`** `docs(session-limits): cadrage Architect — gestion des limites de session des agents` **Contenu (cadrage uniquement, 3 fichiers / +212 lignes) :** - `ARCHITECTURE.md` (+185, nouvelle §21) - `.ideai/memory/session-limit-handling-design.md` (nouveau, design validé) - `.ideai/memory/MEMORY.md` (+1, pointeur) Le runtime `.ideai/` non pertinent (conversations, layouts, agents.json…) a été **exclu** du diff comme demandé. **État git après commit (LOCAL, aucun push) :** - `feature/agent-session-limits` @ `fa5b826` ← `develop` @ `401c18a` ← `main` @ `9a8a89f` (tag `v0.1.0`). - Working tree : reste uniquement du runtime `.ideai/conversations/…` non commité (bruit, ignoré pour le cadrage). La branche est prête : le dev peut démarrer **LS1 (domaine)**. Je reviendrai dans la boucle pour committer chaque lot vert. -- **Prompt:** LS1 (couche domaine) de la feature « limites de session » est VERT : DevBackend a livré le code pur, QA a écrit 24 tests, `cargo test -p domain` = 165 passed / 0 failed, zéro régression. Commite LS1 sur feature/agent-session-limits (commit suivant après le cadrage fa5b826). Périmètre LOCAL uniquement, aucun push. Fichiers de prod (domaine) modifiés/créés par DevBackend : - crates/domain/src/ports.rs (variante ReplyEvent::RateLimited) - crates/domain/src/readiness.rs (variante ReadinessSignal::RateLimited + classify) - crates/domain/src/session_limit.rs (NOUVEAU : SessionLimit, ResumePlan, RateLimitSource, plan_resume) - crates/domain/src/profile.rs (RateLimitPattern + champ + builder) - crates/domain/src/events.rs (5 variantes DomainEvent) - crates/domain/src/lib.rs (mod + re-exports) Plus les tests QA dans ces mêmes fichiers (#[cfg(test)]). N'inclus PAS le runtime .ideai/ non pertinent. Message conventionnel en français. IMPORTANT — décision de merge : NE merge PAS vers develop maintenant. Le workspace complet (`cargo build --workspace`) NE COMPILE PAS tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On ne merge feature/* → develop que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Confirme juste le hash du commit LS1 et l'état git. -- **Response:** LS1 (couche domaine) committé sur `feature/agent-session-limits`, après le cadrage `fa5b826`. **Commit : `0bf1eb3`** `feat(session-limits): LS1 — couche domaine (détection + plan de reprise)` **Contenu (6 fichiers domaine, prod + tests inline / +647 lignes) :** - `crates/domain/src/session_limit.rs` (nouveau) - `crates/domain/src/ports.rs` (ReplyEvent::RateLimited) - `crates/domain/src/readiness.rs` (ReadinessSignal::RateLimited + classify) - `crates/domain/src/profile.rs` (RateLimitPattern + champ + builder) - `crates/domain/src/events.rs` (5 variantes DomainEvent) - `crates/domain/src/lib.rs` (mod + re-exports) Runtime `.ideai/` non pertinent **exclu**. QA : `cargo test -p domain` = 165 passed / 0 failed, zéro régression. **Décision de merge : PAS de merge vers `develop`** — conforme à ta consigne. Le workspace ne compile pas tant que LS2 n'a pas ajouté l'arm `ReplyEvent::RateLimited` côté infra (match exhaustifs). On n'intègre `feature/* → develop` que sur une tranche qui compile de bout en bout (cible : LS1+LS2+LS4 = niveau 1 Claude complet). Je reste sur la branche feature. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 0bf1eb3 (HEAD) └ fa5b826 docs cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/conversations/…` non commité (ignoré). Dev peut enchaîner sur LS2. -- **Prompt:** LS2 (adapter Claude niveau 1, infra) est VERT : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. Commite LS2 sur feature/agent-session-limits (après LS1 0bf1eb3). LOCAL uniquement, aucun push. Fichier de prod modifié : crates/infrastructure/src/session/claude.rs (parse_event émet ReplyEvent::RateLimited ; nouvelle fonction pure parse_reset_ms + helpers ; parseur ISO maison ; doccomments T4). Tests QA : dans crates/infrastructure/src/session/mod.rs (#[cfg(test)]) — 26 nouveaux tests + 2 tests existants mis à jour vers le nouveau contrat ; et crates/infrastructure/src/session/conformance.rs (#[cfg(test)] : RateLimited ajouté aux événements non terminaux autorisés). Exclu le runtime .ideai/. Message conventionnel français. Décision merge : TOUJOURS PAS de merge vers develop (le workspace ne compile pas encore — 2 erreurs résiduelles attendues dans app-tauri, relèvent de LS7/LS8 ; et la réconciliation T4 reste à faire en LS4). On vise la tranche LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile, pour le premier merge. Confirme le hash LS2 et l'état git. - **Response:** LS2 (adapter Claude niveau 1, infra) committé sur `feature/agent-session-limits`, après LS1 `0bf1eb3`. **Commit : `a1755e5`** `feat(session-limits): LS2 — adapter Claude niveau 1 (infra)` **Contenu (3 fichiers infra, prod + tests inline / +545 lignes) :** - `crates/infrastructure/src/session/claude.rs` (prod : parse_event → ReplyEvent::RateLimited, parse_reset_ms + helpers, parseur ISO maison, doccomments T4) - `crates/infrastructure/src/session/mod.rs` (26 nouveaux tests QA + 2 alignés) - `crates/infrastructure/src/session/conformance.rs` (RateLimited en non-terminal autorisé) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 188 passed / 0 failed, zéro régression. **Décision de merge : TOUJOURS PAS de merge vers `develop`** — conforme. Le workspace ne compile pas encore (2 erreurs résiduelles attendues dans app-tauri → LS7/LS8 ; réconciliation T4 → LS4). Premier merge visé sur la tranche **LS1+LS2+LS3+LS4 = niveau 1 Claude de bout en bout qui compile**. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ a1755e5 (HEAD) └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Working tree : seul du runtime `.ideai/` non commité (ignoré). Dev peut enchaîner sur LS3. - **Prompt:** LS3 (port Scheduler + adapter TokioScheduler) est VERT : `cargo test -p infrastructure` = 195 passed / 0 failed (7 nouveaux tests scheduler, relancés 3× sans flaky), LS1/LS2 toujours verts. Builds domaine + infra OK 0 warning. Le workspace complet ne compile toujours pas (2 erreurs résiduelles inchangées dans app-tauri : chat.rs:185 → LS8, events.rs:261 → LS7 ; la réconciliation T4 reste en LS4). C'est TOI qui décides quoi et quand committer/merger. Je te livre l'état, tu tranches. Fichiers de prod LS3 : - crates/domain/src/ids.rs (ScheduleId via typed_id!) - crates/domain/src/ports.rs (trait Scheduler + enum ScheduledTask) - crates/domain/src/lib.rs (re-exports) - crates/infrastructure/src/scheduler/mod.rs (NOUVEAU : TokioScheduler) - crates/infrastructure/src/lib.rs (pub mod scheduler + re-export) Tests QA : dans crates/infrastructure/src/scheduler/mod.rs (#[cfg(test)]). Runtime .ideai/ à exclure du diff. Décide : faut-il committer LS3 maintenant (et avec quel message) ? Et concernant le merge vers develop — rappel du contexte : le premier merge envisagé portait sur la tranche LS1+LS2+LS3+LS4 (niveau 1 Claude de bout en bout qui compile), or le workspace ne compile pas encore (LS4 + app-tauri manquants). Dis-moi ta décision (commit LS3 sur la feature ? attendre la tranche compilable pour merger ? autre ?) et exécute-la. Confirme hash + état git. - **Response:** Décision (agent Git) : **commit LS3 sur la feature, PAS de merge vers `develop`.** **Pourquoi :** chaque lot vert mérite son commit atomique sur la branche feature (traçabilité, bissection facile), mais le merge `feature/* → develop` exige une tranche qui **compile de bout en bout** — règle d'or. Or le workspace ne compile toujours pas (réconciliation T4 en LS4 + 2 erreurs résiduelles app-tauri : `chat.rs:185`→LS8, `events.rs:261`→LS7). Donc on continue d'accumuler sur la feature ; premier merge maintenu sur la tranche compilable (LS1+LS2+LS3+LS4 niveau 1 Claude, voire jusqu'à LS7/LS8 si la compilation workspace n'est verte qu'à ce moment-là). **Commit : `253310b`** `feat(session-limits): LS3 — port Scheduler + adapter TokioScheduler` **Contenu (5 fichiers prod + tests inline / +389 lignes) :** - domaine : `ports.rs` (trait Scheduler + enum ScheduledTask), `ids.rs` (ScheduleId), `lib.rs` (re-exports) - infra : `scheduler/mod.rs` (nouveau, TokioScheduler), `lib.rs` (pub mod + re-export) Runtime `.ideai/` **exclu**. QA : `cargo test -p infrastructure` = 195 passed / 0 failed (7 tests scheduler, 3× sans flaky) ; LS1/LS2 verts ; builds domaine+infra 0 warning. **État git (LOCAL, aucun push) :** ``` feature/agent-session-limits @ 253310b (HEAD) └ a1755e5 LS2 adapter Claude └ 0bf1eb3 LS1 domaine └ fa5b826 cadrage └ 401c18a (develop) ← main @ 9a8a89f (tag v0.1.0) ``` Dev peut enchaîner sur LS4 (service application + réconciliation T4). Je tranche le merge dès que le workspace compile. @@ -23,4 +17,10 @@ objective: Nouvelle feature cadrée par Architect, prête à démarrer le dével - **Prompt:** Slice LS7-front (React/TS, UI limites de session) terminée et VERTE sur `feature/agent-session-limits`. État tests : `npx vitest run src/features/agents/` → 4 fichiers, 63 tests passés (39 existants + 24 nouveaux), 0 régression. Typecheck `tsc --noEmit` propre. Fichiers à committer (frontend/ uniquement) : - src/domain/index.ts — 5 variantes ajoutées au union DomainEvent (agentRateLimited/ResumeScheduled/ResumeCancelled/Resumed/RateLimitSuspected). - src/ports/index.ts — `cancelResume(agentId)` ajouté à InputGateway. - src/adapters/input.ts — TauriInputGateway.cancelResume → invoke("cancel_resume", { agentId }). - src/adapters/mock/index.ts — MockInputGateway.cancelResume (cancelledResumes/cancelResumeResult). - src/features/agents/useAgents.ts — état limitByAgent + action cancelResume. - src/features/agents/AgentLimitBadge.tsx — NOUVEAU (badge + compte à rebours + bouton Annuler + helpers purs). - src/features/agents/AgentsPanel.tsx — câblage du badge. - src/features/agents/useAgentsLimits.test.tsx — NOUVEAU (13 tests, QA). - src/features/agents/AgentLimitBadge.test.tsx — NOUVEAU (11 tests, QA). Hors périmètre (NE PAS committer) : .ideai/** (artefacts runtime/mémoire). Aucun fichier backend touché. NOTE : il existe un test PRÉEXISTANT rouge SANS rapport avec cette slice — `src/adapters/mock/mock.test.ts:16` asserte « thirteen gateways » alors qu'il y en a 14 (gateway `permission` déjà présente dans HEAD committé eca2ba9). Compteur périmé, pas causé par LS7-front. À toi de voir si tu veux que je fasse corriger ce compteur (tâche séparée DevFrontend) avant/après — dis-moi. Questions : 1) Committe la slice LS7-front maintenant ? Sous quel message ? 2) Le merge --no-ff de la feature complète vers develop : on attend que je tranche le dernier point de scope (filet humain niveau 3 — voir consultation Architect en cours : peut nécessiter une slice backend+front supplémentaire `set_resume_at`), donc NE merge pas encore vers develop — confirme que tu te limites au commit de cette slice et qu'on garde le merge pour quand je te recontacte « feature complète ». Tranche et exécute le commit. - **Response:** Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »). 1) ✅ Commit fait. Hash **4fad042** : `feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)` - 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests). - Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests. - **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement. 2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect). Concernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte. État branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**. - **Prompt:** Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente. État branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés : ═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══ Implémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed). Fichiers : - crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable). - crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante). - crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`. - crates/application/tests/session_limit_service.rs — +6 tests (QA). - crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA). Aucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled). ═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══ LS8-front (typecheck propre, 109 tests verts) : - frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway. - frontend/src/adapters/input.ts — `setResumeAt` → invoke("set_resume_at", { agentId, resetsAtMs }). - frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]). - frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste). - frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré. - frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt. - frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8. Fix test hygiène (DevFrontend recommande commit SÉPARÉ) : - frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles. Hors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici). QUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer. -- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/−1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert). \ No newline at end of file +- **Response:** ✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`. ═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══ J'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits : - **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)` (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18) - **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)` (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26) - **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)` (mock.test.ts isolé ; 1 fichier, +2/−1) Artefacts `.ideai/**` et `node_modules/` exclus comme demandé. ═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══ Décision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire). - Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8). - `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e). - La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant. Note d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge. ⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite. Prochaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert). +- **Response:** Topologie tranchée et opération git exécutée. Le dev démarre sur **`feature/orchestrator-designation`**. **Décision : branche dédiée créée depuis `develop`.** Pourquoi cette base : - HEAD était bien sur `feature/agent-skill-awareness` (pas détaché — le statut initial trompait). - La feature « orchestrateur du projet » est **fonctionnellement indépendante** de skill-awareness. Vérifié au niveau fichiers : leur seul recoupement est `lifecycle.rs`. Les fichiers cœur de ta feature (`context_guard.rs`, `domain/fileguard.rs`, `agent.rs`, `events.rs`, infra `fileguard/mod.rs`) ne sont **pas** touchés par skill-awareness, et `develop` contient déjà le câblage du ContextGuard (commit 8452333) sur lequel ta feature s'appuie. - `feature/agent-skill-awareness` a 4 commits non mergés dans `develop` et **n'est pas confirmé vert** de ton côté → règle d'or : je ne le merge pas et je ne le prends pas comme base (cela tirerait du travail non vérifié dans la nouvelle feature). Branche pure depuis l'intégration = feature mergeable indépendamment. Ce que j'ai fait : 1. Commit de l'état runtime qui traînait dans le working tree (`.ideai/` : conversations, layouts, mémoire + note design non suivie) **sur `feature/agent-skill-awareness`** pour partir d'un arbre propre — `5be8987 chore(wip): état runtime .ideai …`. 2. `checkout develop` → `checkout -b feature/orchestrator-designation`. Base = `8452333` (HEAD de develop). Arbre propre, branche active = `feature/orchestrator-designation`. DevBackend/DevFrontend + QA peuvent attaquer T1..T8 dessus. ⚠️ Point d'attention pour le futur merge : `lifecycle.rs` sera modifié à la fois par cette feature et par skill-awareness (non mergé). À l'intégration de l'une puis l'autre dans `develop`, un conflit sur `lifecycle.rs` est probable — résoluble au merge, rien à anticiper côté dev. Reviens vers moi une fois T1..T8 verts pour le merge `feature/orchestrator-designation → develop`. Note locale uniquement : aucune action sortante (`push`) effectuée. +- **Prompt:** Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Commande attendue : créer un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, le supprimer immédiatement si la création réussit, puis rapporter le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée. +- **Response:** Résultat du test d'écriture minimal dans `/home/anthony/Documents/Projects/IdeA/.git/` : `/usr/bin/bash: line 1: /home/anthony/Documents/Projects/IdeA/.git/.idea-write-test-2-1781903701754795712: Read-only file system` La création a échoué, donc aucun fichier temporaire n'a été créé ni supprimé. Aucune commande Git (`add`, `commit`, etc.) n'a été lancée. +- **Prompt:** Contexte: l'utilisateur veut que nous terminions les chantiers ouverts d'IdeA, en autonomie, mais en respectant le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas. Etat local observe par Main: - Branche courante: `feature/orchestrator-designation`. - `git log --oneline --decorate -n 12` montre `8452333 (HEAD -> feature/orchestrator-designation, develop) merge(orchestrator): intègre le câblage du ContextGuard dans develop`, puis `181727d fix(orchestrator): câble le ContextGuard au composition root`, puis `origin/develop` plus ancien. - `git status --short --branch`: worktree dirty avec environ 75 fichiers modifies/non suivis. - Le diff touche notamment `.ideai/*`, `CLAUDE.md`, `crates/app-tauri`, `crates/application`, `crates/domain`, `crates/infrastructure`, `frontend/src/features/projects/ProjectsView.tsx`, `frontend/src/features/terminals/useWritePortal*`, et un nouveau `crates/application/src/diag.rs`. - Les memoires projet indiquent des chantiers ouverts: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte pendant la vie d'un agent, UX des delegations/conversations, documentation architecture a resynchroniser. La feature limites de session est indiquee terminee et mergee dans develop. Demande utilisateur: expliquer la situation a Git pour qu'il prenne la meilleure decision sur la gestion des branches, sachant que certains chantiers ont peut-etre des branches deja ouvertes et qu'il faudra peut-etre plusieurs branches. Ta mission Git: 1. Inspecte l'etat git local autant que necessaire. 2. Decide la strategie de branches pour poursuivre ces chantiers sans perdre les changements actuels. 3. Dis a Main sur quelle branche travailler pour le premier chantier logique, si des branches doivent etre creees/switch, et quoi faire du worktree dirty actuel. 4. Ne push pas. Ne fais aucune action sortante. 5. Si tu executes des commandes ou prends une decision, rends compte clairement: branche courante, branches pertinentes, statut dirty, decision et justification. Important: si tu recois cette tache via IdeA, reponds avec `idea_reply`. +- **Response:** Décision Git — stratégie de branches pour la suite. ## État constaté (réel) - Branche courante : `feature/orchestrator-designation` @ `8452333`. Elle n'a **aucun commit propre** par rapport à `develop` ; `develop` n'a qu'1 commit de plus = `40982d4 chore(release): passe la version à 0.3.0`. - Worktree dirty = **73 fichiers trackés modifiés + 2 non suivis** (`crates/application/src/diag.rs`, `.ideai/memory/conversation-rotation-safety-design.md`). - Analyse du diff : il est **massivement mono-thème = chantier orchestrateur/designation** (orchestrat ×164, designation ×47) + nouveau sink de diagnostic `diag.rs` pour le rendez-vous inter-agents. Les autres mots-clés (sandbox, fileguard, ratelimit…) sont du bruit de fichiers de test touchés, pas un 2e chantier. - `main` = `29232dd` release 0.3.0 (local, non poussé). `develop` = `40982d4`. ## Audit des branches existantes (vs develop) - `feature/agent-session-limits` → **intégrée dans develop, supprimable** (cohérent avec la mémoire « feature terminée et mergée »). - `feature/agent-skills` → 1 commit non intégré. - `feature/agent-skill-awareness` → 5 commits non intégrés (inclut la lignée skill-awareness + le fix cold-start). - `fix/cold-start-delivery-race` → 3 commits non intégrés (sous-ensemble probable de skill-awareness). ## Ma décision **1. Le worktree dirty est DÉJÀ sur la bonne branche. On n'y touche pas, on ne stashe pas, on ne switche pas.** Ce WIP EST le chantier orchestrateur-designation en cours. Le déplacer ou ouvrir une autre branche par-dessus mélangerait/perdrait ~73 fichiers. Tant qu'il n'est pas figé, `feature/orchestrator-designation` est verrouillée sur ce chantier. **2. Premier chantier logique = FINIR celui-ci (orchestrateur : diag sink + designation), pas en ouvrir un nouveau.** Raison Git : on ne peut pas créer proprement une nouvelle `feature/*` depuis `develop` avec autant de non-commité sur le worktree. Le chemin critique est donc : - Dev finalise sur `feature/orchestrator-designation` → QA verte → **je commit** (commits atomiques : je sépare le code source `crates/` des fichiers runtime `.ideai/*` et de `CLAUDE.md`, qui partent dans un commit `chore` distinct). - Puis **rebase trivial** de la branche sur `develop` (un seul commit d'écart, le bump 0.3.0, aucun conflit attendu) et **merge `--no-ff` → develop**. - Ensuite seulement `develop` redevient une base propre pour la suite. **3. Pour Main, tout de suite :** travaille le **premier chantier sur `feature/orchestrator-designation`** (branche courante, ne rien changer). Confie à Architect le cadrage du périmètre exact « orchestrateur-designation + diag », puis Dev/QA. Quand c'est vert, reviens vers moi pour commit + merge. **4. Ensuite (une fois develop à jour) :** chaque chantier restant indépendant = **une `feature/* dédiée créée depuis develop`** — donc plusieurs branches, une par sujet (persistance conversationnelle, handoff cross-profile, live-state partagé, refresh mémoire/contexte à chaud, UX délégations, resync doc archi). Je créerai chacune au moment où Main me l'annoncera, jamais toutes d'avance. **5. Nettoyage que je propose AVANT d'ouvrir de nouvelles branches** (pour éviter la prolifération) : supprimer `feature/agent-session-limits` (intégrée). Et statuer avec toi sur `feature/agent-skills` / `feature/agent-skill-awareness` / `fix/cold-start-delivery-race` : soit on les intègre dans develop via le cycle (si vivantes et vertes), soit on les abandonne. Dis-moi le sort de chacune ; je n'efface rien sans ton feu vert. Aucune action sortante effectuée. Aucun push, aucun tag, aucune suppression de branche pour l'instant. +- **Prompt:** Retour Git pour fermeture du chantier `orchestrator-designation`. Contexte: - Branche courante: `feature/orchestrator-designation`. - Ta décision initiale: finir ce WIP sur cette branche, puis commit atomique(s), rebase/merge local vers develop si tests verts ou résidu qualifié. - Ne push pas. Aucune action sortante. Travail réalisé: - DevBackend a corrigé `crates/application/src/orchestrator/context_guard.rs` pour compiler avec le nouveau modèle `AgentManifest { version, entries, orchestrator }` et `may_write_directly(..., &OrchestratorDesignation)`. - Main a écrit des checkpoints mémoire: `checkpoint-orchestrator-designation-restart`, `checkpoint-orchestrator-designation-backend-compile-fix`, `checkpoint-orchestrator-designation-qa-verdict`. Validation QA: Commandes vertes: - `cargo fmt --all -- --check`: OK. - `cargo test -p application --test orchestrator_service`: OK, 45 passed. - `cargo test -p application`: OK, suite application complète verte. - `cargo test -p infrastructure input --lib`: OK, 35 passed. - `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed. - `cd frontend && npx tsc --noEmit`: OK. Résidu: - `cargo test -p app-tauri --lib` et `cargo test --workspace` restent rouges uniquement sur 8 tests app-tauri qui bindent un vrai Unix socket (`mcp_bridge` loopback + `state::mcp_e2e_loopback_tests`). - QA qualifie comme contrainte sandbox/environnement: même une sonde Node minimale échoue à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et `/tmp`. Ta mission: 1. Inspecte le diff/status actuel. 2. Crée les commits atomiques appropriés. Attention: le worktree contient aussi des fichiers runtime `.ideai/*`, `CLAUDE.md`, mémoire/checkpoints; décide quoi committer, quoi laisser non committé, quoi inclure dans un commit séparé si c'est pertinent. 3. Décide et exécute le merge local éventuel vers `develop` si tu juges le chantier terminé selon QA. 4. Rends compte clairement: commits créés (hash + message), merge/rebase effectué ou non, branche finale, fichiers laissés dirty, et prochaine recommandation. Réponds via `idea_reply`. \ No newline at end of file diff --git a/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl b/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl index cc0e068..d2318a2 100644 --- a/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl +++ b/.ideai/conversations/6bc594e8-a37c-0dbd-1de6-6e3b73002cb4/log.jsonl @@ -19,3 +19,9 @@ {"id":"21dcba39-7c5a-4191-8ddb-f13ab7eed54e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781676433750,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — LS7-front committée, pas de merge develop (j'attends ton « feature complète »).\n\n1) ✅ Commit fait. Hash **4fad042** :\n`feat(session-limits): LS7-front — UI limites de session (badge + compte à rebours + filet humain)`\n- 9 fichiers, +702/−1, dont 3 créations (AgentLimitBadge.tsx + ses 2 fichiers de tests).\n- Périmètre strictement frontend : domain/index.ts, ports/index.ts, adapters/input.ts, adapters/mock/index.ts, useAgents.ts, AgentLimitBadge.tsx, AgentsPanel.tsx + les 2 tests.\n- **Exclus comme demandé** (non-stagés) : `.ideai/**` (conversations, layouts, memory) et `node_modules/` (non suivi — au passage, il traîne en untracked ; à gitignorer dans une tâche d'hygiène séparée si ce n'est pas déjà couvert, mais hors périmètre ici). Aucun fichier backend touché. Commit atomique = frontend seulement.\n\n2) ✅ Confirmé : **je me limite à ce commit, pas de merge vers `develop`.** Je garde le `--no-ff` d'intégration de la feature complète pour quand tu me recontactes « feature complète », une fois tranché le scope du filet humain niveau 3 (slice `set_resume_at` éventuelle, cf. consultation Architect).\n\nConcernant le **test préexistant rouge** `mock.test.ts:16` (« thirteen gateways » → 14 réelles, gateway `permission` déjà dans HEAD eca2ba9) : compteur périmé sans rapport avec LS7. Mon avis Git : à corriger **avant le merge d'intégration**, en **commit séparé** (`fix(test): …` ou `chore(test): …`), pour ne pas merger `develop` avec une suite rouge. Lance une tâche DevFrontend dédiée quand tu veux ; tu peux l'enchaîner maintenant ou la grouper avec l'éventuelle slice `set_resume_at` — dans tous les cas elle aura son propre commit, et je ne mergerai pas tant que toute la suite n'est pas verte.\n\nÉtat branche : `feature/agent-session-limits` → … ea94e75 (LS6) · 9df5923 (LS7 back) · **4fad042 (LS7 front, HEAD)**."} {"id":"8a9caf1e-94cf-4d45-905a-caab1363aa99","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678668760,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature session-limits COMPLÈTE (3 niveaux) et verte. Reste à committer 2 slices, puis on peut envisager le merge d'intégration `feature/agent-session-limits → develop` que tu gardais en attente.\n\nÉtat branche actuel : … 9df5923 (LS7 back) · 4fad042 (LS7 front, HEAD). Travail NON committé depuis, à committer maintenant en 2 commits séparés :\n\n═══ COMMIT A — LS8-backend (filet humain niveau 3, backend) ═══\nImplémenté par DevBackend, testé par QA (application: 15 tests session_limit_service / app-tauri: 4 wiring, + régressions vertes, 0 failed).\nFichiers :\n- crates/application/src/agent/session_limit.rs — refactor privé `arm_scheduled` (param `resets_at_ms` brut ajouté) partagé par `on_rate_limited` + nouvelle `pub fn confirm_human_resume(agent_id, node_id, conversation_id, resets_at_ms: i64)` (source Human, réutilise la branche Scheduled, annulable).\n- crates/app-tauri/src/commands.rs — nouvelle commande `set_resume_at(agent_id, resets_at_ms) -> Result<(), ErrorDto>` (résout node_id via node_for_agent + conversation_id best-effort, NOT_FOUND si pas de cellule vivante).\n- crates/app-tauri/src/lib.rs — `set_resume_at` enregistrée après `cancel_resume`.\n- crates/application/tests/session_limit_service.rs — +6 tests (QA).\n- crates/app-tauri/tests/session_limit_wiring.rs — +2 tests (QA).\nAucun événement nouveau (réutilise AgentRateLimited + AgentResumeScheduled).\n\n═══ COMMIT B — LS8-front + fix test (DevFrontend a demandé 2 commits ; à toi de voir si tu sépares ou regroupes) ═══\nLS8-front (typecheck propre, 109 tests verts) :\n- frontend/src/ports/index.ts — `setResumeAt(agentId, resetsAtMs)` sur InputGateway.\n- frontend/src/adapters/input.ts — `setResumeAt` → invoke(\"set_resume_at\", { agentId, resetsAtMs }).\n- frontend/src/adapters/mock/index.ts — MockInputGateway.setResumeAt (resumeArmings[]).\n- frontend/src/features/agents/useAgents.ts — action setResumeAt (sans mutation optimiste).\n- frontend/src/features/agents/AgentLimitBadge.tsx — formulaire de saisie d'heure sur l'état suspected sans heure + helper pur `timeInputToEpochMs` ; TODO LS7 retiré.\n- frontend/src/features/agents/AgentsPanel.tsx — câblage onSetResumeAt.\n- frontend/src/features/agents/AgentLimitBadge.test.tsx — tests mis à jour au nouveau contrat + couverture LS8.\nFix test hygiène (DevFrontend recommande commit SÉPARÉ) :\n- frontend/src/adapters/mock/mock.test.ts — compteur gateways 13→14 (« thirteen »→« fourteen » + `permission` dans l'array). Vérifié : 14 gateways réelles.\n\nHors périmètre (NE PAS committer) : .ideai/** (runtime/mémoire), node_modules/ (untracked — DevFrontend signalait qu'il traîne, à gitignorer dans une tâche d'hygiène séparée, hors périmètre ici).\n\nQUESTIONS : 1) Committe COMMIT A puis les commits front (LS8-front + fix-test, séparés ou groupés selon ton jugement). Donne-moi les hashes + messages. 2) La suite complète est maintenant verte (Rust: domain/application/app-tauri ; front: agents + adapters/mock 109 tests) et la surface produit session-limits est complète sur les 3 niveaux. Décides-tu MAINTENANT le merge `--no-ff` vers develop (rebase si besoin) ? Si oui, exécute-le et donne-moi le résultat. Si tu veux un dernier garde-fou (ex. run de la suite complète des deux côtés avant merge), dis-le et je le fais lancer."} {"id":"6df9f7b7-fcc4-4b87-94db-33dce2d76f13","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781678766649,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"✅ 3 commits + merge d'intégration faits. La feature session-limits est intégrée dans `develop`.\n\n═══ 1) Les 3 commits (sur feature/agent-session-limits) ═══\nJ'ai séparé le fix-test de la feature (hygiène isolée, comme recommandé par DevFrontend) → 3 commits :\n\n- **c480d28** `feat(session-limits): LS8-backend — filet humain niveau 3 (set_resume_at)`\n (session_limit.rs + commands.rs + lib.rs + tests service/wiring ; 5 fichiers, +394/−18)\n- **5d9dd32** `feat(session-limits): LS8-front — filet humain niveau 3 (saisie d'heure de reprise)`\n (ports/input/mock + useAgents + AgentLimitBadge(.tsx/.test) + AgentsPanel ; 7 fichiers, +222/−26)\n- **3f3504e** `fix(test): corrige le compteur de gateways du mock (13 → 14)`\n (mock.test.ts isolé ; 1 fichier, +2/−1)\n\nArtefacts `.ideai/**` et `node_modules/` exclus comme demandé.\n\n═══ 2) Merge `--no-ff` → develop : OUI, exécuté ═══\nDécision : la surface produit est complète sur les 3 niveaux et tout est vert des deux côtés → je merge. Pas de rebase nécessaire : `feature` était **12 ahead / 0 behind** `develop` (aucune divergence, base linéaire).\n\n- Merge commit : **d7041c5** `merge(session-limits): intégration de la feature limites de session (3 niveaux)` — stratégie ort, **0 conflit**, 47 fichiers / +5157/−45 (cumul LS2→LS8).\n- `develop` est maintenant à **d7041c5**, contenant toute la chaîne LS2 (a1755e5) → LS8 (3f3504e).\n- La branche `feature/agent-session-limits` est **conservée** (pas supprimée) : je la garde tant que tu n'as pas confirmé que l'intégration est définitivement validée. Dis-moi si tu veux que je la supprime maintenant.\n\nNote d'exécution : les artefacts runtime `.ideai/**` non committés bloquaient le switch de branche → je les ai **stashés le temps du merge puis restaurés** (stash poppé, working tree intact, rien perdu). Aucun de ces artefacts n'est entré dans les commits applicatifs ni dans le merge.\n\n⚠️ Périmètre **strictement local** : aucun `push`. `develop` local est en avance de 58 commits sur `origin/develop` — je ne pousse rien sans ta validation explicite.\n\nProchaines options possibles, à ta main : suppression de `feature/agent-session-limits`, et/ou push de `develop` vers origin (action sortante → nécessite ton feu vert)."} +{"id":"ecc8a067-d70d-461e-a551-31b066b00b8e","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781735767522,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Topologie tranchée et opération git exécutée. Le dev démarre sur **`feature/orchestrator-designation`**.\n\n**Décision : branche dédiée créée depuis `develop`.**\n\nPourquoi cette base :\n- HEAD était bien sur `feature/agent-skill-awareness` (pas détaché — le statut initial trompait).\n- La feature « orchestrateur du projet » est **fonctionnellement indépendante** de skill-awareness. Vérifié au niveau fichiers : leur seul recoupement est `lifecycle.rs`. Les fichiers cœur de ta feature (`context_guard.rs`, `domain/fileguard.rs`, `agent.rs`, `events.rs`, infra `fileguard/mod.rs`) ne sont **pas** touchés par skill-awareness, et `develop` contient déjà le câblage du ContextGuard (commit 8452333) sur lequel ta feature s'appuie.\n- `feature/agent-skill-awareness` a 4 commits non mergés dans `develop` et **n'est pas confirmé vert** de ton côté → règle d'or : je ne le merge pas et je ne le prends pas comme base (cela tirerait du travail non vérifié dans la nouvelle feature). Branche pure depuis l'intégration = feature mergeable indépendamment.\n\nCe que j'ai fait :\n1. Commit de l'état runtime qui traînait dans le working tree (`.ideai/` : conversations, layouts, mémoire + note design non suivie) **sur `feature/agent-skill-awareness`** pour partir d'un arbre propre — `5be8987 chore(wip): état runtime .ideai …`.\n2. `checkout develop` → `checkout -b feature/orchestrator-designation`. Base = `8452333` (HEAD de develop).\n\nArbre propre, branche active = `feature/orchestrator-designation`. DevBackend/DevFrontend + QA peuvent attaquer T1..T8 dessus.\n\n⚠️ Point d'attention pour le futur merge : `lifecycle.rs` sera modifié à la fois par cette feature et par skill-awareness (non mergé). À l'intégration de l'une puis l'autre dans `develop`, un conflit sur `lifecycle.rs` est probable — résoluble au merge, rien à anticiper côté dev. Reviens vers moi une fois T1..T8 verts pour le merge `feature/orchestrator-designation → develop`.\n\nNote locale uniquement : aucune action sortante (`push`) effectuée."} +{"id":"84c3287c-199d-40f1-b8a9-0e255e5c9cc9","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781903693730,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Diagnostic demandé par Main : teste uniquement l’écriture effective dans le `.git` du projet IdeA, de façon minimale et réversible. Commande attendue : créer un fichier temporaire unique sous `/home/anthony/Documents/Projects/IdeA/.git/`, le supprimer immédiatement si la création réussit, puis rapporter le résultat exact (`WRITE_OK` ou l’erreur noyau, notamment `Read-only file system`). Ne lance pas `git add`, `git commit`, ni aucune action Git destructive. Réponds via `idea_reply` si tu reçois ce message comme tâche déléguée."} +{"id":"977c19ae-1a1d-45ac-92c3-d4e804c8caef","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781903707163,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Résultat du test d'écriture minimal dans `/home/anthony/Documents/Projects/IdeA/.git/` :\n\n`/usr/bin/bash: line 1: /home/anthony/Documents/Projects/IdeA/.git/.idea-write-test-2-1781903701754795712: Read-only file system`\n\nLa création a échoué, donc aucun fichier temporaire n'a été créé ni supprimé. Aucune commande Git (`add`, `commit`, etc.) n'a été lancée."} +{"id":"1935b068-04df-4746-bf98-a3708060c2e3","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781937849055,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Contexte: l'utilisateur veut que nous terminions les chantiers ouverts d'IdeA, en autonomie, mais en respectant le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas.\n\nEtat local observe par Main:\n- Branche courante: `feature/orchestrator-designation`.\n- `git log --oneline --decorate -n 12` montre `8452333 (HEAD -> feature/orchestrator-designation, develop) merge(orchestrator): intègre le câblage du ContextGuard dans develop`, puis `181727d fix(orchestrator): câble le ContextGuard au composition root`, puis `origin/develop` plus ancien.\n- `git status --short --branch`: worktree dirty avec environ 75 fichiers modifies/non suivis.\n- Le diff touche notamment `.ideai/*`, `CLAUDE.md`, `crates/app-tauri`, `crates/application`, `crates/domain`, `crates/infrastructure`, `frontend/src/features/projects/ProjectsView.tsx`, `frontend/src/features/terminals/useWritePortal*`, et un nouveau `crates/application/src/diag.rs`.\n- Les memoires projet indiquent des chantiers ouverts: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte pendant la vie d'un agent, UX des delegations/conversations, documentation architecture a resynchroniser. La feature limites de session est indiquee terminee et mergee dans develop.\n\nDemande utilisateur: expliquer la situation a Git pour qu'il prenne la meilleure decision sur la gestion des branches, sachant que certains chantiers ont peut-etre des branches deja ouvertes et qu'il faudra peut-etre plusieurs branches.\n\nTa mission Git:\n1. Inspecte l'etat git local autant que necessaire.\n2. Decide la strategie de branches pour poursuivre ces chantiers sans perdre les changements actuels.\n3. Dis a Main sur quelle branche travailler pour le premier chantier logique, si des branches doivent etre creees/switch, et quoi faire du worktree dirty actuel.\n4. Ne push pas. Ne fais aucune action sortante.\n5. Si tu executes des commandes ou prends une decision, rends compte clairement: branche courante, branches pertinentes, statut dirty, decision et justification.\n\nImportant: si tu recois cette tache via IdeA, reponds avec `idea_reply`."} +{"id":"fe4a3495-d47f-4e11-b4aa-c60b638f4090","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781937944161,"source":{"kind":"agent","agentId":"cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5"},"role":"response","text":"Décision Git — stratégie de branches pour la suite.\n\n## État constaté (réel)\n- Branche courante : `feature/orchestrator-designation` @ `8452333`. Elle n'a **aucun commit propre** par rapport à `develop` ; `develop` n'a qu'1 commit de plus = `40982d4 chore(release): passe la version à 0.3.0`.\n- Worktree dirty = **73 fichiers trackés modifiés + 2 non suivis** (`crates/application/src/diag.rs`, `.ideai/memory/conversation-rotation-safety-design.md`).\n- Analyse du diff : il est **massivement mono-thème = chantier orchestrateur/designation** (orchestrat ×164, designation ×47) + nouveau sink de diagnostic `diag.rs` pour le rendez-vous inter-agents. Les autres mots-clés (sandbox, fileguard, ratelimit…) sont du bruit de fichiers de test touchés, pas un 2e chantier.\n- `main` = `29232dd` release 0.3.0 (local, non poussé). `develop` = `40982d4`.\n\n## Audit des branches existantes (vs develop)\n- `feature/agent-session-limits` → **intégrée dans develop, supprimable** (cohérent avec la mémoire « feature terminée et mergée »).\n- `feature/agent-skills` → 1 commit non intégré.\n- `feature/agent-skill-awareness` → 5 commits non intégrés (inclut la lignée skill-awareness + le fix cold-start).\n- `fix/cold-start-delivery-race` → 3 commits non intégrés (sous-ensemble probable de skill-awareness).\n\n## Ma décision\n\n**1. Le worktree dirty est DÉJÀ sur la bonne branche. On n'y touche pas, on ne stashe pas, on ne switche pas.** Ce WIP EST le chantier orchestrateur-designation en cours. Le déplacer ou ouvrir une autre branche par-dessus mélangerait/perdrait ~73 fichiers. Tant qu'il n'est pas figé, `feature/orchestrator-designation` est verrouillée sur ce chantier.\n\n**2. Premier chantier logique = FINIR celui-ci (orchestrateur : diag sink + designation), pas en ouvrir un nouveau.** Raison Git : on ne peut pas créer proprement une nouvelle `feature/*` depuis `develop` avec autant de non-commité sur le worktree. Le chemin critique est donc :\n - Dev finalise sur `feature/orchestrator-designation` → QA verte → **je commit** (commits atomiques : je sépare le code source `crates/` des fichiers runtime `.ideai/*` et de `CLAUDE.md`, qui partent dans un commit `chore` distinct).\n - Puis **rebase trivial** de la branche sur `develop` (un seul commit d'écart, le bump 0.3.0, aucun conflit attendu) et **merge `--no-ff` → develop**.\n - Ensuite seulement `develop` redevient une base propre pour la suite.\n\n**3. Pour Main, tout de suite :** travaille le **premier chantier sur `feature/orchestrator-designation`** (branche courante, ne rien changer). Confie à Architect le cadrage du périmètre exact « orchestrateur-designation + diag », puis Dev/QA. Quand c'est vert, reviens vers moi pour commit + merge.\n\n**4. Ensuite (une fois develop à jour) :** chaque chantier restant indépendant = **une `feature/* dédiée créée depuis develop`** — donc plusieurs branches, une par sujet (persistance conversationnelle, handoff cross-profile, live-state partagé, refresh mémoire/contexte à chaud, UX délégations, resync doc archi). Je créerai chacune au moment où Main me l'annoncera, jamais toutes d'avance.\n\n**5. Nettoyage que je propose AVANT d'ouvrir de nouvelles branches** (pour éviter la prolifération) : supprimer `feature/agent-session-limits` (intégrée). Et statuer avec toi sur `feature/agent-skills` / `feature/agent-skill-awareness` / `fix/cold-start-delivery-race` : soit on les intègre dans develop via le cycle (si vivantes et vertes), soit on les abandonne. Dis-moi le sort de chacune ; je n'efface rien sans ton feu vert.\n\nAucune action sortante effectuée. Aucun push, aucun tag, aucune suppression de branche pour l'instant."} +{"id":"7f294345-a6f4-494a-aba5-7343fbbdff3f","conversation":"6bc594e8-a37c-0dbd-1de6-6e3b73002cb4","atMs":1781938514544,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Retour Git pour fermeture du chantier `orchestrator-designation`.\n\nContexte:\n- Branche courante: `feature/orchestrator-designation`.\n- Ta décision initiale: finir ce WIP sur cette branche, puis commit atomique(s), rebase/merge local vers develop si tests verts ou résidu qualifié.\n- Ne push pas. Aucune action sortante.\n\nTravail réalisé:\n- DevBackend a corrigé `crates/application/src/orchestrator/context_guard.rs` pour compiler avec le nouveau modèle `AgentManifest { version, entries, orchestrator }` et `may_write_directly(..., &OrchestratorDesignation)`.\n- Main a écrit des checkpoints mémoire: `checkpoint-orchestrator-designation-restart`, `checkpoint-orchestrator-designation-backend-compile-fix`, `checkpoint-orchestrator-designation-qa-verdict`.\n\nValidation QA:\nCommandes vertes:\n- `cargo fmt --all -- --check`: OK.\n- `cargo test -p application --test orchestrator_service`: OK, 45 passed.\n- `cargo test -p application`: OK, suite application complète verte.\n- `cargo test -p infrastructure input --lib`: OK, 35 passed.\n- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed.\n- `cd frontend && npx tsc --noEmit`: OK.\n\nRésidu:\n- `cargo test -p app-tauri --lib` et `cargo test --workspace` restent rouges uniquement sur 8 tests app-tauri qui bindent un vrai Unix socket (`mcp_bridge` loopback + `state::mcp_e2e_loopback_tests`).\n- QA qualifie comme contrainte sandbox/environnement: même une sonde Node minimale échoue à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et `/tmp`.\n\nTa mission:\n1. Inspecte le diff/status actuel.\n2. Crée les commits atomiques appropriés. Attention: le worktree contient aussi des fichiers runtime `.ideai/*`, `CLAUDE.md`, mémoire/checkpoints; décide quoi committer, quoi laisser non committé, quoi inclure dans un commit séparé si c'est pertinent.\n3. Décide et exécute le merge local éventuel vers `develop` si tu juges le chantier terminé selon QA.\n4. Rends compte clairement: commits créés (hash + message), merge/rebase effectué ou non, branche finale, fichiers laissés dirty, et prochaine recommandation.\n\nRéponds via `idea_reply`."} diff --git a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md index da864bf..35e90ac 100644 --- a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md +++ b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/handoff.md @@ -1,15 +1,9 @@ --- -upTo: 267ce6cf-bd44-464d-aea5-c4455652a79e +upTo: be96b537-e241-4277-82d8-fe26cd851e58 objective: CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour --- **Objectif :** CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour -- **Prompt:** Test de la conversation inter-agents IdeA. Peux-tu confirmer que tu reçois bien ce message et répondre via idea_reply avec un court statut de santé (qui tu es, ton rôle, et confirmation que le pont fonctionne) ? -- **Response:** ✅ Message bien reçu — le pont inter-agents IdeA fonctionne. **Qui je suis :** codextest, un agent du projet IdeA. **Rôle :** agent de test/validation rattaché au project root IdeA. **Santé du pont :** opérationnel. J'ai reçu ta tâche (ticket 6ee3458a-a0c9-4adc-9d52-219023af626c) et je te réponds via `idea_reply` comme prévu. Le cycle délégation → réception → réponse est donc validé de bout en bout. Prêt pour la suite quand tu veux. -- **Prompt:** CADRAGE ARCHITECTURE — Système de permissions projet + agents (avant tout code). CONTEXTE PRODUIT (validé par l'utilisateur) : On veut gérer les permissions des agents IA à deux niveaux : 1. Niveau PROJET : permissions par défaut héritées par tous les agents du projet. 2. Niveau AGENT : surcharge fine, agent par agent. Les permissions couvrent (a) les opérations FICHIERS (lecture, écriture/modification, suppression…) et (b) le droit d'exécuter des COMMANDES BASH. Objectif final : quand une permission est définie dans IdeA, l'agent (qu'il soit Claude Code OU Codex) NE REDEMANDE PLUS la permission au run. DÉCISIONS PRODUIT DÉJÀ ARBITRÉES (à respecter, ne pas re-débattre) : - Modèle neutre = CAPABILITIES + SCOPES : capacités typées (read / write / delete / execute-bash), chacune scopable par chemin (globs) et, pour bash, par allowlist/denylist de commandes. - Combinaison projet→agent = HÉRITAGE + OVERRIDE, avec DENY PRIORITAIRE en cas de conflit. - Posture par défaut = si AUCUNE permission n'est définie (ni projet ni agent), on garde le comportement actuel (le moteur demande au run) ; IdeA n'écrit aucune config tant que rien n'est posé. - Le modèle de domaine doit être NEUTRE vis-à-vis du moteur ; chaque runtime PROJETTE ce modèle dans sa config native pour supprimer les prompts au run : * Claude Code → .claude/settings.local.json (permissions.allow/deny + defaultMode acceptEdits/bypassPermissions). * Codex → config.toml (approval_policy + sandbox_mode). CE QUE J'ATTENDS DE TOI (contrat d'architecture, PAS du code) : 1. Le modèle de domaine : entités/VO (ex. PermissionSet, Capability, PathScope, CommandRule…), invariants, et la fonction PURE de résolution (project defaults ⊕ agent overrides → PermissionSet effectif, deny prioritaire). 2. Le ou les PORTS nécessaires (ex. un PermissionProjector/PermissionRenderer par runtime ? un PermissionStore ?) et où ils vivent (domain/application/infra), cohérent avec ta cartographie existante (ARCHITECTURE.md, crates domain/application/infrastructure/app-tauri). 3. La stratégie de PROJECTION par runtime : comment le PermissionSet effectif se traduit concrètement en settings.local.json (Claude) et config.toml (Codex) pour ne plus prompter — y compris les limites/risques (ex. granularité bash de Codex vs Claude, sandbox). 4. Le STOCKAGE/format : où persiste-t-on les permissions (.ideai/ ? agents.json ? un permissions.json ?), schéma JSON proposé, et l'impact sur le manifeste existant. 5. Le découpage en LOT(S) livrable(s) façon §12 (binôme dev+test), avec ordre et dépendances. 6. Points ouverts / spikes (notamment ce qui n'est pas mappable proprement entre Claude et Codex). Réponds via idea_reply avec ce contrat structuré. Sois concret et aligné sur l'hexagonal strict que tu maintiens. -- **Response:** # CONTRAT D'ARCHITECTURE — Permissions projet + agents Cadrage hexagonal strict, aligné sur ARCHITECTURE.md et sur le pattern **déjà éprouvé** `McpConfigStrategy` / `McpServerWiring::to_config_toml` (domaine = rendu pur du fichier de conf ; application = écriture dans le run dir). On **réutilise ce gabarit** au lieu d'inventer un nouveau mécanisme. --- ## 1. Modèle de domaine (`crate domain`, module `permission`) Pur, sans I/O, `serde` autorisé (format persisté = contrainte métier, cf. §1.4 archi). ### Value Objects / entités ``` Capability (enum) = Read | Write | Delete | ExecuteBash Effect (enum) = Allow | Deny PathScope (VO) = { globs: Vec } // globs relatifs au project root, jamais absolus hors-root CommandRule (VO) = { matcher: CommandMatcher, effect: Effect } CommandMatcher (VO) = Exact(String) | Prefix(String) | Glob(Glob) // ex. "git", "npm *", "rm -rf *" PermissionRule (VO) = { capability: Capability, effect: Effect, paths: PathScope, // applicable à read/write/delete (ignoré pour bash) commands: Vec, // applicable seulement à ExecuteBash } PermissionSet (entité-valeur) = { rules: Vec, // posture explicite quand rien ne matche une capacité : fallback: Posture, // Ask (défaut) | Allow | Deny } Posture (enum) = Ask | Allow | Deny ``` ### Invariants (testables sans I/O) - `PathScope.globs` : relatifs, **pas de `..`**, pas de chemin absolu sortant du root (réutiliser la garde de `ConventionFile.target`). - `ExecuteBash` est la **seule** capacité portant des `CommandRule` ; les autres portent un `PathScope`. Une règle bash avec `paths` non vide = erreur ; une règle fichier avec `commands` = erreur. - `Glob` non vide et compilable. - Un `PermissionSet` peut contenir Allow ET Deny sur la même capacité (scopes différents) — c'est légal, résolu au run par spécificité + priorité deny. - **Posture par défaut produit** : `PermissionSet` ABSENT (Option::None) ≠ `PermissionSet` vide. None ⇒ IdeA n'écrit RIEN, comportement actuel préservé. C'est porté par l'option au niveau résolution, pas par un set vide. ### Fonction PURE de résolution ```rust // domain::permission pub fn resolve( project: Option<&PermissionSet>, // défauts projet agent: Option<&PermissionSet>, // overrides agent ) -> Option ``` Règles : 1. `project == None && agent == None` ⇒ **`None`** (rien posé → on ne projette rien, le moteur prompte au run). **C'est l'invariant produit clé.** 2. Sinon, fusion **héritage + override** : - On part des règles projet, on superpose les règles agent. - **DENY PRIORITAIRE** : pour une capacité+scope donnés, si un `Deny` matche (projet ou agent), il **gagne** sur tout `Allow`, quel que soit le niveau. (deny-wins, non surchargeable par un allow plus spécifique — décision produit, on ne re-débat pas.) - `fallback` : l'agent peut resserrer mais le deny projet reste prioritaire. 3. Résultat = `EffectivePermissions` (VO de sortie, déjà aplati/normalisé), prêt à projeter. C'est le **seul** input des projecteurs. `resolve` est totale, déterministe, 100 % testable (table de vérité héritage × deny-wins × fallback). --- ## 2. Ports & emplacement On ajoute **deux** ports. On **ne crée pas** de port « renderer » côté infra : le rendu est PUR donc il vit dans le domaine (exactement comme `to_config_toml` aujourd'hui). | Port / élément | Type | Couche | Rôle | |---|---|---|---| | `PermissionStore` | **trait (port)** | `domain/ports` | `load_project_permissions(project) -> Option` ; `load_agent_permissions(project, agent_id) -> Option` ; `save_*`. Implémenté par un `FsPermissionStore` (infra) ou intégré à l'`AgentContextStore`/`ProjectStore` existant. | | `PermissionProjection` | **trait de domaine (pas un port I/O)** | `domain/permission` | `fn project(&self, eff: &EffectivePermissions) -> RuntimeConfigArtifact` où `RuntimeConfigArtifact = { rel_path: String, contents: String, merge: MergeMode }`. **Pur**, une impl par runtime (`ClaudeProjection`, `CodexProjection`). Calque exact de `McpServerWiring::to_config_toml`. | - **Application** : un use case `ResolveAndProjectPermissions` (ou plus simplement une fonction appelée DANS `LaunchAgent`) qui : charge via `PermissionStore` → `resolve(...)` → si `Some`, appelle la `PermissionProjection` du runtime du profil → écrit l'artefact via `FileSystem` dans le run dir. Si `None`, ne touche à rien. - **Infra** : `FsPermissionStore` (tokio::fs + serde_json). Écriture des artefacts = `FileSystem` déjà existant. **Aucun nouvel adapter PTY/process.** - **Composition root** (`app-tauri`) : injecte `FsPermissionStore` + map `ProfileKind → Arc`. Point d'insertion concret : dans `application/src/agent/lifecycle.rs`, là où `claude_settings_seed` / le `config.toml` Codex sont déjà écrits dans le run dir. **La projection permissions REMPLACE le seed blanket `bypassPermissions` actuel** quand un PermissionSet est défini ; sinon le comportement actuel reste (voir §3 risque). --- ## 3. Stratégie de projection par runtime L'artefact est écrit dans le **run dir isolé** (`.ideai/run//`), jamais dans le `~/.claude` / `~/.codex` global — exactement comme le MCP wiring aujourd'hui. C'est ce qui supprime les prompts sans polluer la machine. ### Claude Code → `{runDir}/.claude/settings.local.json` Mapping `EffectivePermissions` → schéma natif Claude : - `Capability::ExecuteBash` + `CommandRule(effect=Allow)` → `permissions.allow: ["Bash(:*)"]` (ou pattern exact). Deny → `permissions.deny`. - `Read`/`Write`/`Delete` + `PathScope` → `permissions.allow/deny` avec `Read()`, `Edit()`, `Write()`. (Delete ≈ pas de capacité native dédiée → mappé sur Bash `rm` + Write ; voir spike.) - `fallback`/posture globale → `permissions.defaultMode` : - `Allow` global large ⇒ `acceptEdits` (ou `bypassPermissions` si l'utilisateur l'assume) ; - posture restrictive avec allowlist ⇒ `default` (ask) + listes `allow`. - `MergeMode` = merge sur l'existant (on **garde** la logique de merge actuelle de `claude_settings_seed`, on ne l'écrase pas brutalement). ### Codex → `{runDir}/.codex/config.toml` (CODEX_HOME déjà câblé) Granularité bien plus grossière que Claude — c'est le risque principal : - Codex n'a pas d'allowlist de commandes par règle fine : il a `approval_policy` (`never` / `on-failure` / `on-request` / `untrusted`) et `sandbox_mode` (`read-only` / `workspace-write` / `danger-full-access`). - Mapping pragmatique : - PermissionSet majoritairement `Allow` write+bash sans deny bloquant ⇒ `approval_policy = "never"` + `sandbox_mode = "workspace-write"`. - `Read` seul ⇒ `sandbox_mode = "read-only"`. - Présence de `Deny` ⇒ **on NE peut pas exprimer un deny fin** dans Codex : on rabat sur `approval_policy = "on-request"` (Codex redemandera pour les cas sensibles) **OU** on documente la perte de fidélité (voir §6). - Les denylists de commandes Claude (ex. `rm -rf /`) n'ont **pas d'équivalent** Codex → couvert seulement par `sandbox_mode = workspace-write` (qui borne au workspace) + spike. **Limite assumée** : la projection est *best-effort fidèle*. Le domaine reste la source de vérité ; chaque projecteur fait au mieux et on expose un `ProjectionFidelity { lossless: bool, warnings: Vec }` dans l'artefact pour remonter à l'UI « cette permission n'est pas exprimable telle quelle sous Codex ». --- ## 4. Stockage & format Décision : **fichier dédié `.ideai/permissions.json`** (pas dans `agents.json`). Raisons : SRP (le manifeste mappe md↔template↔sync, pas la sécurité), diff/review propre, et on évite de versionner les overrides agent au milieu du manifeste. Schéma proposé : ```json { "version": 1, "project": { "fallback": "ask", "rules": [ { "capability": "read", "effect": "allow", "paths": ["**/*"] }, { "capability": "write", "effect": "allow", "paths": ["src/**", "crates/**"] }, { "capability": "write", "effect": "deny", "paths": [".git/**", "**/*.pem"] }, { "capability": "execute-bash", "effect": "allow", "commands": ["git *", "cargo *", "npm *"] }, { "capability": "execute-bash", "effect": "deny", "commands": ["rm -rf *", "sudo *"] } ] }, "agents": { "": { "fallback": "ask", "rules": [ { "capability": "execute-bash", "effect": "deny", "commands": ["*"] } ] } } } ``` - `project` absent + `agents[id]` absent ⇒ `resolve` rend `None` ⇒ rien d'écrit (posture par défaut respectée). - **Impact manifeste** : `agents.json` **inchangé**. Lien faible par `agentId` (clé partagée). Pas de migration de l'existant requise. - Mémoire projet : ce fichier voyage avec le projet (versionnable), cohérent avec la décision « mémoire/contexte partagés au project root ». À afficher dans l'UI permissions (lot front). --- ## 5. Découpage en lots (façon §12, binôme dev+test) | # | Lot | Contenu | Dépend de | |---|---|---|---| | **LP0** | **Domaine permissions** | VO/entités (`Capability`, `PathScope`, `CommandRule`, `PermissionRule`, `PermissionSet`, `Posture`), invariants, `resolve()` pure (héritage + deny-wins + None-si-rien). Tests = tables de vérité. **Zéro I/O.** | L0 | | **LP1** | **PermissionStore + format** | trait `PermissionStore` (domain), `FsPermissionStore` (infra), schéma `.ideai/permissions.json`, (dé)sérialisation. Tests intégration tmpdir. | LP0 | | **LP2** | **Projection Claude** | `ClaudeProjection` (domaine pur) + branchement dans `lifecycle.rs` REMPLAÇANT le seed blanket quand `Some`. `ProjectionFidelity`. Tests = snapshot du `settings.local.json` rendu. | LP0, LP1 | | **LP3** | **Projection Codex** | `CodexProjection` (mapping approval_policy/sandbox_mode), warnings de fidélité. Tests snapshot `config.toml`. | LP0, LP1 | | **LP4** | **Câblage LaunchAgent + IPC** | use case `ResolveAndProjectPermissions` invoqué à l'activation, composition root (map runtime→projection), commandes Tauri `get/set_permissions`. | LP2, LP3 | | **LP5** | **UI permissions** | éditeur projet + override agent, badges de fidélité (« non exprimable sous Codex »). Gateways mock. | LP4 | Ordre = dépendances directes. LP2 et LP3 parallélisables après LP1. --- ## 6. Points ouverts / spikes 1. **Granularité bash Codex** (bloquant fidélité) : pas d'allow/deny par commande → on ne peut pas refléter une denylist fine. Spike : valider si `sandbox_mode=workspace-write` + `approval_policy` suffit, sinon assumer la perte + warning UI. **À trancher avant LP3.** 2. **Capacité `Delete`** : ni Claude ni Codex n'ont de capacité « delete » native distincte de write/bash `rm`. Spike : mapper Delete → (Write sur le chemin) + (Bash `rm`/`unlink`) ? ou capacité purement informative côté FileGuard (cf. `domain/src/fileguard.rs` déjà présent — possible convergence : la FileGuard interne pourrait CONSOMMER le même `EffectivePermissions` que la projection, garantissant cohérence I/O réelle vs config CLI). 3. **Cohérence FileGuard ↔ projection** : on a déjà `fileguard.rs`. Décision à figer : `EffectivePermissions` devient-il la source unique consommée *à la fois* par la FileGuard (enforcement réel des outils MCP) ET par les projecteurs (suppression des prompts CLI) ? Recommandé : **oui**, une seule source — sinon double vérité. Spike d'alignement. 4. **`bypassPermissions` actuel** : le seed blanket donne aujourd'hui l'autonomie totale. Migration : tant qu'aucun `permissions.json` n'existe, on **garde** ce seed (compat). Dès qu'un set est posé, la projection prend le relais. Valider qu'on ne régresse pas l'autonomie des agents existants (Main, Architect…). 5. **Globs ↔ patterns natifs** : la syntaxe glob du domaine doit se traduire sans perte vers `Bash(...)`/`Edit(...)` de Claude et vers le sandbox Codex. Spike de table de correspondance. 6. **Édition hors-app du permissions.json** (content hash / drift) — aligné sur le mécanisme drift template existant, mineur. --- **Synthèse** : un module domaine `permission` (modèle + `resolve` pure + 2 projections pures), un port `PermissionStore`, branchement dans `LaunchAgent`/`lifecycle.rs` sur le pattern MCP existant, stockage `.ideai/permissions.json` sans toucher au manifeste, 6 lots binômables. Le seul vrai risque produit est la fidélité Codex (granularité bash + deny), géré par un drapeau de fidélité explicite plutôt que par une fausse équivalence. Je recommande de trancher les spikes #1 et #3 (convergence FileGuard) avant de lancer LP3/LP4. -- **Prompt:** LP0 implémenté et vert. DevBackend a dû trancher un point de sémantique non couvert explicitement par ton contrat — j'ai besoin de ta validation (ou correction) avant que QA fige les tables de vérité. LE POINT : pour une règle bash `PermissionRule { capability: ExecuteBash, effect: , commands: [CommandRule...] }` où `commands` est NON VIDE — que signifie le `effect` de NIVEAU RÈGLE (``) pour les commandes qui ne matchent AUCUN `CommandRule` ? Deux interprétations : - (A) choix actuel de DevBackend : le `effect` de niveau règle n'est PAS consulté pour les commandes non matchées ; seules les `CommandRule` individuelles décident, et les commandes non matchées retombent sur le `fallback` (Ask/Allow/Deny). Avantage : permet « allow git*, allow cargo*, et prompt tout le reste » avec fallback=Ask. Une règle bash à `commands` VIDE reste un verdict blanket (effect) pour toute commande. - (B) catch-all : pour une règle bash avec commandes, le `effect` de niveau règle s'applique en catch-all aux commandes non matchées par ses CommandRule. DevBackend a choisi (A) et l'a documenté dans le rustdoc. Pour moi (A) est cohérent avec la posture produit « deny prioritaire + fallback Ask par défaut » et évite qu'une règle allow se transforme en allow-tout implicite. Confirme (A), ou impose (B) si tu vois un risque sur la projection Claude/Codex (notamment : Codex n'ayant pas de granularité par commande, comment (A) vs (B) se projette en approval_policy/sandbox_mode). Réponds via idea_reply : tranche (A) ou (B), + une phrase sur l'impact projection Codex. Court. -- **Response:** **Tranché : (A) confirmée.** C'est le bon choix et il est cohérent avec mon contrat : chaque `CommandRule` porte déjà son propre `effect`, donc sur une règle bash à `commands` non vide, l'`effect` de niveau règle est **inerte** (ne pas le consulter pour les non-matchés ; les non-matchés retombent sur `fallback`). Une règle bash à `commands` VIDE reste un verdict blanket. Garde le deny-wins prioritaire sur les `CommandRule` matchées. Recommande à QA d'ajouter une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour éviter toute régression future. **Impact projection Codex** : (A) projette *plus* fidèlement — Codex n'ayant aucune granularité par commande, l'allowlist fine est de toute façon perdue, et c'est le `fallback` (la posture globale) qui pilote `approval_policy`/`sandbox_mode` (Ask→on-request, Allow→never+workspace-write, Deny→on-request best-effort) ; (B) aurait introduit un second curseur catch-all en conflit avec `fallback`, ambigu à projeter. Donc (A) simplifie aussi le projecteur Codex. - **Prompt:** Cadrage du lot LP3 — projection des permissions vers les CLI (Claude + Codex), avec swap cross-profile automatique. CONTEXTE (déjà en place, tout vert) : - domain/src/permission.rs : modèle pur + `resolve()` → `EffectivePermissions` (profil-agnostique), avec `decide_file(capability, path) -> Posture` et `decide_bash(command) -> Posture` (Posture = Ask|Allow|Deny, deny-wins). - Port `PermissionStore` + `FsPermissionStore` lisant `.ideai/permissions.json`. - Use cases application (get/update/resolve) + commandes Tauri + UI `PermissionsPanel` montée. Slice de gestion terminé. - `EffectivePermissions` / `decide_*` ne sont consommés NULLE PART hors tests : rien n'est appliqué aux agents. - Le swap cross-profile existe déjà (commit P8d : « swap cross-profile préserve l'id de paire + handoff »). EXIGENCE PRODUIT (utilisateur) : « Fonctionnel pour Codex comme pour Claude. Si je passe un agent de Claude à Codex, que les permissions soient exportées de l'un vers l'autre automatiquement. » Décision d'archi pressentie à valider/affiner : les permissions restent stockées une seule fois (profil-agnostique) ; on introduit un Port `PermissionProjector` avec une implémentation par profil (Claude → settings.json allow/deny/ask ; Codex → config sandbox), sélectionnée selon le profil actif au lancement/handoff. Le swap Claude→Codex re-projette automatiquement via l'autre projecteur depuis la même `EffectivePermissions`. CE QUE J'ATTENDS DE TOI (réponds via idea_reply) : 1. Validation/correction du contrat du Port `PermissionProjector` : signature exacte (entrée `EffectivePermissions` + cwd/projectRoot ? sortie : écriture fichier vs valeur ?), où il vit (domain port, impls infrastructure), et comment on évite le couplage au profil concret (registre profil→projector). 2. Où exactement brancher la projection dans le chemin de lancement d'agent et dans le chemin de swap/handoff cross-profile (cite les modules : application/src/agent/lifecycle.rs, orchestrator/service.rs, etc.). 3. Stratégie de nettoyage : quand un profil n'est plus actif (swap), faut-il retirer/écraser la config projetée du profil précédent ? Règle précise. 4. Découpage en sous-tâches dev/test concret (ce que je donnerai à DevBackend puis QA), dans l'ordre. Ne code pas : donne le contrat et le plan. - **Response:** CADRAGE LP3 — Projection des permissions vers les CLI (Claude + Codex) + re-projection au swap cross-profile ⚠️ Constat préalable (à dire à DevBackend avant tout) : la projection EXISTE DÉJÀ, mais en l'état c'est de la dette à formaliser, pas un greenfield. Dans `application/src/agent/lifecycle.rs` on a aujourd'hui : - Claude : `seed_cli_permissions` (l.1620) → `claude_settings_seed` (l.2063) → écrit `.claude/settings.local.json`. Sélection IMPLICITE : sniff du nom de convention-file == `CLAUDE.md`. Et surtout **non-clobbering** (`if exists return`, l.1642). - Codex : `apply_codex_cli_permission_args` (l.2226, args `--sandbox`/`--ask-for-approval`) + `codex_config_toml` (l.2258, clés `sandbox_mode`/`approval_policy`). Sélection IMPLICITE : branche `McpConfigStrategy::TomlConfigHome` DANS `apply_mcp_config` (l.1838). Donc la projection Codex est PARASITÉE par la présence de MCP : un profil Codex sans MCP ne reçoit aucune sandbox. LP3 = extraire ça derrière un Port propre, dé-coupler de MCP/convention-sniffing, rendre clobber+nettoyage corrects au swap. Pas de réécriture des règles de traduction (elles sont bonnes et déjà testées l.2957-3068), juste relocalisation + cadrage. ──────────────────────────────────────── 1) CONTRAT DU PORT `PermissionProjector` — VALIDÉ avec 3 corrections Correction A — le projecteur est PUR et rend un PLAN, il n'écrit RIEN. Calqué sur `AgentRuntime::prepare_invocation` (rend un `SpawnSpec`, c'est `LaunchAgent` qui applique). Idem ici : le projecteur traduit `EffectivePermissions` → valeur ; `LaunchAgent` applique (writes via `self.fs`, fold args/env dans `spec`). Bénéfice : testable sans FS (comme le domaine), I/O centralisée au même endroit que `apply_injection`. On n'injecte PAS `FileSystem` dans chaque projecteur. Signature (domaine — voir corr. B pour le lieu) : ```rust pub struct ProjectionContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum ProjectedFile { /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away. Replace { rel_path: String, contents: String }, /// Fichier co-possédé (ex. config.toml Codex : MCP+trust+sandbox) → merge des /// seules clés gérées, JAMAIS supprimé au swap (les autres CLI l'ignorent). MergeToml { rel_path: String, managed_tables: Vec, managed_keys: Vec, contents: String }, } pub struct PermissionProjection { pub files: Vec, pub args: Vec, // ex. ["--sandbox","workspace-write",...] pub env: Vec<(String,String)>, } pub trait PermissionProjector: Send + Sync { fn key(&self) -> ProjectorKey; /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI : /// c'est l'invariant produit de `resolve()` — ne JAMAIS verrouiller un projet non configuré). fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection; /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage swap). fn owned_replace_paths(&self) -> Vec; } ``` Entrée : `Option<&EffectivePermissions>` + `ProjectionContext{project_root, run_dir}` (les deux sont nécessaires : Claude embarque `additionalDirectories=[project_root]`, et tout est écrit dans le run dir). Sortie : un PLAN (fichiers + args + env), pas une écriture. Correction B — où il vit. Trait + value types `PermissionProjection`/`ProjectedFile`/`ProjectionContext`/`ProjectorKey` → DANS LE DOMAINE (`domain/src/permission.rs`, à côté de `EffectivePermissions`). C'est un port piloté, pur, qui ne référence que des types domaine déjà présents. Les IMPLÉMENTATIONS (`ClaudePermissionProjector`, `CodexPermissionProjector`) → DANS L'INFRASTRUCTURE (`crates/infrastructure/src/permission/` ; à créer). Justification = exactement le pattern `AgentRuntime`(domain) / `CliAgentRuntime`(infra) : le format concret d'un `settings.json` Claude ou des modes sandbox Codex est un détail technique d'UNE CLI ⇒ adapter (§5). On y déplace tel quel `claude_settings_seed`, `apply_codex_cli_permission_args`, `codex_config_toml` (partie permissions) + leurs tests. Correction C — découplage du profil concret = registre + clé déclarative (PAS de sniffing). - Ajouter au profil déclaratif un champ `projector: Option` (`AgentProfile`, `domain/src/profile.rs`) — cohérent avec « profil = donnée éditable » (§9). Les profils builtin posent `"claude"` / `"codex"`. - Registre `PermissionProjectorRegistry = HashMap>`, construit au composition root (`app-tauri`) et injecté dans les use cases. - Sélection : `registry.get(profile.projector)`. `None` ⇒ aucune projection (natif). - Fallback de migration (profiles.json déjà sur disque sans le champ) : si `projector == None`, dériver la clé via l'heuristique actuelle (convention-file `CLAUDE.md` → claude ; `StructuredAdapter::Codex` ou `TomlConfigHome` → codex). On garde donc la compat sans imposer un re-seed du store global. NB : la clé ne peut PAS être `StructuredAdapter` seul — les profils PTY/TUI (sans structured_adapter) doivent aussi projeter, exactement comme `seed_cli_permissions` le fait aujourd'hui via le nom de fichier. D'où une `ProjectorKey` dédiée. ──────────────────────────────────────── 2) OÙ BRANCHER Chemin de LANCEMENT — `LaunchAgent::execute` (lifecycle.rs ~l.1085) : - Injecter `Arc` dans `LaunchAgent` (builder `with_permission_projectors`, optionnel ⇒ zéro régression call-sites/tests legacy, même pattern que `with_handoff_provider`). - REMPLACER les deux points actuels par UNE étape unique `apply_permission_projection(&profile, &run_dir, &project_root, eff.as_ref(), &mut spec)` : 1. supprimer l'appel `seed_cli_permissions` (l.1234) ; 2. SORTIR la projection Codex de `apply_mcp_config` (l.1838/1814/1826) — `apply_mcp_config` ne doit plus toucher `sandbox_mode`/`approval_policy`/`--sandbox` ; il ne fait QUE du MCP. - Placement de la nouvelle étape : juste après `apply_injection` (l.1294) et après `apply_mcp_config` (l.1312), donc AVANT le split structuré/PTY (l.1321) et avant `pty.spawn` (l.1361). Critique : c'est en amont du split ⇒ les deux chemins (structuré ET PTY brut) héritent de la projection, comme aujourd'hui. Les `args`/`env` du plan sont foldés dans `spec` avant que `launch_structured` ou `pty.spawn` ne le consomment. - Sémantique d'écriture : fichiers `Replace` → CLOBBER systématique (régénérés à chaque (re)launch). Justification déjà actée pour `.mcp.json` (l.1735) : fichier IdeA-managed, non édité par l'utilisateur, sinon la re-projection au swap est IMPOSSIBLE (c'est le bug actuel de `seed_cli_permissions` non-clobbering). Fichiers `MergeToml` → merge des seules clés gérées (réutiliser `set_top_level_toml_value`/`replace_toml_table` existants). Chemin de SWAP/HANDOFF — `ChangeAgentProfile::execute` (lifecycle.rs l.418, relaunch construit en l.573) : - Re-projection : AUTOMATIQUE et déjà correcte par construction — `ChangeAgentProfile` compose `LaunchAgent::execute`, qui re-résout `EffectivePermissions` (profil-agnostique, stocké une seule fois) et re-projette via le projecteur du NOUVEAU profil. Aucune nouvelle branche de projection à ajouter ici. Idem pour tous les auto-launch de `orchestrator/service.rs` (l.671, 1296, 1373) qui passent par `LaunchAgent` ⇒ couverts gratuitement. - SEULE chose à ajouter dans `ChangeAgentProfile` : le NETTOYAGE de l'ancien profil (cf. §3), exécuté avant la relance. Il faut donc que `ChangeAgentProfile` connaisse l'ancien projecteur + le `FileSystem` (il a déjà l'ancien `profile_id` via le manifeste avant mutation, et le run dir est stable par agent id : `agent_run_dir(root, agent.id)`, l.1217 — invariant clé : le swap RÉUTILISE le même run dir, d'où la nécessité du nettoyage). ──────────────────────────────────────── 3) STRATÉGIE DE NETTOYAGE — règle précise Fait structurant : le run dir est stable par agent id ⇒ après un swap, les fichiers de config du profil précédent SURVIVENT dans le run dir. Un Claude→Codex laisse un `.claude/settings.local.json` (Codex l'ignore : inoffensif fonctionnellement, mais c'est une policy périmée/divergente qui fuit ⇒ à nettoyer). Un Codex→Claude laisse les clés sandbox dans `config.toml` (lu seulement par Codex ⇒ inoffensif). Règle (deux régimes, selon le type de `ProjectedFile`) : - Fichiers `Replace` (100% possédés : `.claude/settings.local.json`) → au swap, supprimer (best-effort) `owned_replace_paths(ancien) − owned_replace_paths(nouveau)`. Au launch normal, clobber (régénérés). - Fichiers `MergeToml` (co-possédés : `config.toml` Codex, partagé avec MCP+trust) → JAMAIS supprimés au swap. On retire uniquement les clés gérées (`sandbox_mode`/`approval_policy`) si on swappe AWAY de Codex et qu'on veut être strict ; recommandation pragmatique : ne rien retirer (le fichier n'est lu que par Codex, qui n'est plus actif) — le re-launch Codex futur réécrira les clés. Donc cleanup effectif = suppression des seuls fichiers `Replace` orphelins. - Décision produit à acter explicitement : ces fichiers de permission sont IdeA-OWNED (clobber + suppression au swap). Conséquence assumée : une édition manuelle de `.claude/settings.local.json` n'est PAS préservée — la source de vérité est le `PermissionsPanel` / `.ideai/permissions.json`. C'est le SEUL moyen de tenir l'exigence « permissions exportées automatiquement d'une CLI à l'autre au swap ». (Renverse le comportement non-clobbering actuel de `seed_cli_permissions` : à documenter dans le commit.) ──────────────────────────────────────── 4) DÉCOUPAGE DEV/TEST (ordre de livraison) LP3-1 — DOMAINE (port + clé). Dev : ajouter `PermissionProjector`, `PermissionProjection`, `ProjectedFile`, `ProjectionContext`, `ProjectorKey` dans `domain/src/permission.rs` ; ajouter `projector: Option` à `AgentProfile` (+ `#[serde(default)]`, builder, builtins claude/codex). QA : (dé)sérialisation du profil avec/sans le champ (compat) ; defaults builtin. `cargo test -p domain`. LP3-2 — INFRA (projecteurs). Dev : créer `infrastructure/src/permission/` ; y DÉPLACER `claude_settings_seed`+helpers et `apply_codex_cli_permission_args`+`codex_config_toml`(partie perms) sous `ClaudePermissionProjector` / `CodexPermissionProjector` (purs, rendent un `PermissionProjection`) ; implémenter `owned_replace_paths`. QA : golden par posture (Allow/Ask/Deny), deny-wins, escaping JSON, args Codex, clés TOML — relocaliser/réutiliser les tests existants (lifecycle.rs l.2957-3068). `cargo test -p infrastructure`. LP3-3 — APPLICATION (wiring launch + dé-couplage MCP). Dev : `PermissionProjectorRegistry` + injection dans `LaunchAgent` (builder optionnel) ; nouvelle étape `apply_permission_projection` ; SUPPRIMER `seed_cli_permissions` ; PURGER la projection perms hors de `apply_mcp_config`. QA (fs mocké) : profil Claude → settings écrit ET clobbé au 2e launch ; profil Codex SANS MCP → args sandbox foldés + config.toml écrit (preuve du dé-couplage MCP) ; `projector=None` → rien d'écrit, rien dans spec ; `eff=None` → projection vide. `cargo test -p application`. LP3-4 — SWAP (nettoyage). Dev : injecter registry+fs dans `ChangeAgentProfile` ; avant relaunch, supprimer les `Replace` orphelins (ancien−nouveau). QA : Claude→Codex supprime `.claude/settings.local.json` et écrit la config Codex ; Codex→Claude réécrit `settings.local.json` frais reflétant les perms re-résolues (clobber) ; swap vers profil `projector=None` nettoie sans réécrire ; vérifier que l'id de paire + handoff (P8d) restent intacts (non-régression). LP3-5 — COMPOSITION ROOT (`app-tauri`). Dev : instancier le registre (claude+codex) et l'injecter dans `LaunchAgent` ET `ChangeAgentProfile`. QA : smoke de wiring + `cargo test` workspace complet vert + suites permissions existantes vertes. Règle d'or maintenue : aucun lot vert tant que `cargo test -p ` du lot ne passe pas ; LP3-5 exige le workspace entier vert. Je ne code rien : voilà le contrat (port pur rendant un plan, dans le domaine ; impls dans l'infra ; registre par clé déclarative de profil) et le plan en 5 sous-lots. Point d'attention n°1 pour DevBackend : c'est d'abord un refactor d'extraction (le code de traduction existe et est testé), dont le cœur est le dé-couplage Codex↔MCP et le passage clobber+nettoyage. - **Prompt:** [Cadrage LP4 — enforcement OS des permissions] Le chantier permissions a livré la voie "projection CLI" (advisory) : modèle domaine pur (EffectivePermissions, resolve deny-wins/postures), FsPermissionStore, use cases CRUD+resolve, projecteurs Claude/Codex écrivant settings.local.json / config.toml au launch, composition root + commandes Tauri, UI PermissionsPanel. Tout vert. Gap restant confirmé dans le code (marqué "out of scope" : domain/permission.rs:12, domain/fileguard.rs:16, infrastructure/fileguard/mod.rs:18, application/orchestrator/context_guard.rs:23) : l'enforcement OS *airtight* (révocation réelle des accès FS), que la mémoire archi décrit comme "sandbox OS Landlock + résumé injecté". Aucun générateur de résumé ni échafaudage Landlock n'existe encore. Ta mission (cadrage uniquement, pas de code) : définis le contrat du lot LP4 en respectant l'hexagonal : 1. Le PORT domaine (nom, signature) qui transforme EffectivePermissions résolues en un plan d'enforcement OS pur (rulesets path RO/RW/exec), sans I/O ni dépendance Landlock dans le domaine. 2. La frontière exacte : où s'applique le ruleset (process agent au spawn PTY, pré-exec) vs le rôle de l'adapter infra Landlock (Linux d'abord ; Windows/macOS = stubs no-op explicites). Adapters par OS, pas par CLI. 3. Le point de câblage dans le launch-path (lifecycle.rs) en cohérence avec la projection déjà écrite (même EffectivePermissions). 4. Le volet "résumé de permissions injecté" dans le contexte .md de l'agent : port/fonction pure de génération + point d'injection (à côté du convention file/seed). 5. La stratégie de test par couche (domaine/app sans I/O via fakes ; test d'intégration Linux réel sous-process accès refusé). Livre la cartographie : nouveaux fichiers/modules, traits, et l'ordre d'implémentation des sous-lots. Réponds via idea_reply. @@ -23,4 +17,10 @@ objective: CADRAGE ARCHITECTURE (pas de code) — Support de la délégation int - **Prompt:** Feature session-limits — cadrage du lot LS7 (câblage au composition root). Project root: /home/anthony/Documents/Projects/IdeA. État : LS1→LS6 committés. Tout le mécanisme existe mais n'est PAS branché dans app-tauri : `application::agent::session_limit::SessionLimitService` (ports injectés : Clock, Scheduler, EventBus, AgentResumer) n'est référencé nulle part dans le composition root → aucun `DomainEvent::AgentRateLimited/ResumeScheduled/Resumed/RateLimitSuspected` n'est jamais émis, aucune reprise armée. LS7 doit câbler au composition root (app-tauri), conformément à ARCHITECTURE §21. J'ai besoin d'une carte de câblage précise (PAS de code), répondant à ces points, en nommant les fichiers/structs/fonctions exacts du repo où chaque tap se branche : 1. **Instanciation du service** : où, dans app-tauri (state.rs ? di/composition root ?), instancier `SessionLimitService::new(clock, scheduler, events, resumer)`. Quel `Scheduler` concret (TokioScheduler déjà en infra), quel EventBus (TokioBroadcastEventBus partagé), quel Clock. Cycle de vie/partage (Arc) cohérent avec l'existant. 2. **Port AgentResumer → LaunchAgent** : comment implémenter `AgentResumer::resume(agent_id, node_id, conversation_id, resume_prompt)` par-dessus le mécanisme de lancement existant (`LaunchAgent` + `AgentSessionFactory` + `SessionPlan::Resume`). Où vit ce code (un adapter app-tauri ?), et quelles dépendances il capture. Référence : les passerelles voisines `HandoffProvider`/`ProviderSessionProvider` mentionnées dans session_limit.rs. 3. **Tap détection niveau 1 (structuré)** : où le flux `ReplyStream`/`ReplyEvent` des sessions structurées est consommé aujourd'hui (le « pump » qui draine les tours — cf. chat.rs `chunk_from_event`), et comment y intercepter `ReplyEvent::RateLimited{resets_at_ms}` pour appeler `service.on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms)`. Comment récupérer node_id + conversation_id à ce point. 4. **Tap détection niveau 2 (PTY)** : où la sortie PTY des agents non-structurés est drainée, et où instancier un `infrastructure::RateLimitParser` (règle de sélection `ratelimit::applies(profile)`) pour le nourrir des fragments et router un `SessionLimit` détecté vers le service. Respect de l'anti-double-détection §21.10-4. 5. **Drain du Scheduler** : comment `TokioScheduler` remet les `ScheduledTask::ResumeAgent` échus (canal de remise) et où câbler le récepteur qui appelle `service.execute_resume(task)` sur le runtime Tokio. 6. **Commande Tauri d'annulation** : exposer `cancel_resume(agent_id)` comme `#[tauri::command]` (la fenêtre annulable côté UI l'appellera en LS8) — où l'enregistrer dans le `generate_handler!`. Donne aussi : ordre d'implémentation recommandé, points de fragilité/race à surveiller pour QA, et confirme que rien ne viole l'hexagonal (domaine pur, regex/parse confinés infra). Rends via idea_reply. - **Response:** # LS7 — Carte de câblage « session-limits » au composition root Tous les chemins sont relatifs au project root. Aucun code ici : seulement les points d'ancrage exacts (fichier · struct/fn · ligne). Faits vérifiés en amont : - Service : `crates/application/src/agent/session_limit.rs` — `SessionLimitService::new(clock, scheduler, events, resumer)` ; API publique : `on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms)`, `execute_resume(task).await -> Result<(),AppError>`, `cancel_resume(agent_id) -> bool`. Port à implémenter : `AgentResumer::resume(agent_id, node_id, conversation_id, resume_prompt)` (async). - Scheduler : `crates/infrastructure/src/scheduler/mod.rs` — `TokioScheduler::new(tx: UnboundedSender, clock: Arc)`. Réexporté `infrastructure::TokioScheduler`. Il **pousse** la `ScheduledTask` échue dans `tx` ; le drain détient `rx`. - Parser N2 : `crates/infrastructure/src/ratelimit/mod.rs` — `RateLimitParser::new(&RateLimitPattern) -> Option`, `detect(text, now_ms) -> Option`, et la règle de sélection `ratelimit::applies(profile) -> bool` (= `structured_adapter.is_none() && rate_limit_pattern.is_some()`). Réexportés `infrastructure::RateLimitParser` / `infrastructure::ratelimit::applies`. - LS6 a déjà câblé les 6 variantes `DomainEvent::Agent{RateLimited,ResumeScheduled,ResumeCancelled,Resumed,RateLimitSuspected}` → DTO → relay (`crates/app-tauri/src/events.rs:208-253, 426-452`). Rien à faire côté event wire. --- ## 1. Instanciation du service (composition root) **Fichier : `crates/app-tauri/src/state.rs`, dans `AppState::build` (à partir de la ligne 355).** Réutiliser les adapters déjà construits en tête de `build` : - `clock` (l.358, `SystemClock`, implémente `Clock::now_millis`) → caster en `Arc` comme partout (cf. `Arc::clone(&clock) as Arc`). - `event_bus` (l.357) → `events_port` (l.369, `Arc`), le **même** bus partagé que tout le reste (donc l'`AgentRateLimited` émis passera par `spawn_relay`). Séquence d'instanciation (à placer après la construction de `launch_agent` l.668-700 et de `project_store` l.750, car le resumer en dépend ; idéalement juste avant le bloc orchestrateur l.917) : 1. `let (resume_tx, resume_rx) = tokio::sync::mpsc::unbounded_channel::();` 2. `let scheduler = Arc::new(TokioScheduler::new(resume_tx, Arc::clone(&clock) as Arc)) as Arc;` 3. `let resumer = Arc::new(AppAgentResumer::new(Arc::clone(&launch_agent), Arc::clone(&store_port), )) as Arc;` (cf. §2). 4. `let session_limit_service = Arc::new(SessionLimitService::new(Arc::clone(&clock) as Arc, scheduler, Arc::clone(&events_port), resumer));` **Cycle de vie / partage :** ajouter un champ `pub session_limit_service: Arc` à la struct `AppState` (déclaration vers l.326, à côté de `orchestrator_service`) et le renvoyer dans le littéral final (l.967-1055). Le `Arc` est partagé par : (a) la commande `cancel_resume` (§6), (b) la tâche de drain (§5), (c) les taps de détection N1/N2 (§3/§4) qui appellent `on_rate_limited`. Le `resume_rx` n'entre **pas** dans `AppState` : il est *moved* dans la tâche de drain spawné à l'intérieur de `build` (§5), exactement comme `sweep_stalled` (l.899-911). Imports à ajouter en tête de `state.rs` : `application::{SessionLimitService, AgentResumer}`, `domain::ports::{Scheduler, ScheduledTask}`, `infrastructure::TokioScheduler`. --- ## 2. Port `AgentResumer` → `LaunchAgent` (adapter app-tauri) **Nouvel adapter dans `crates/app-tauri/src/state.rs`**, à côté des passerelles stateless existantes `AppHandoffProvider` (l.100-109) / `AppProviderSessionProvider` (l.119-128) / `AppRecordTurnProvider` (l.81-90) — même patron `impl application::Trait for AppXxx`. `impl application::AgentResumer for AppAgentResumer { async fn resume(agent_id, node_id, conversation_id, resume_prompt) -> Result<(), AppError> }` recompose un `LaunchAgentInput` et appelle `self.launch_agent.execute(...)` (le **même** `Arc` que la commande `launch_agent`, l.1140). C'est `LaunchAgent` qui, via `with_handoff_provider` / `with_provider_session_provider` (l.687-695) et le routage §17.4, applique déjà `SessionPlan::Resume` quand un `conversation_id` est présent (chemin de reprise P7/P8b/§15). Le `resume_prompt` (constante `application::RESUME_PROMPT`) est le premier tour — pour le chemin **PTY natif** (composition B-2, voir l.645-653), il devra être écrit dans le PTY après spawn ; pour le chemin structuré (dormant), passé en premier `send`. **À cadrer dev :** où injecter ce premier tour. La voie la plus cohérente avec l'existant est de réutiliser le médiateur d'entrée (`MediatedInbox`/portail d'écriture PTY, l.883-892) plutôt qu'un write direct. **⚠️ Point dur de conception (à trancher, c'est LE risque du lot) :** `AgentResumer::resume` et `ScheduledTask::ResumeAgent` ne portent **pas** de `project_id`, alors que `LaunchAgentInput` exige un `Project` complet + `rows`/`cols` + `mcp_runtime` (cf. commande l.1126-1152). Le service est volontairement model/projet-agnostique. Il faut donc que l'adapter **résolve le `Project` à partir de l'`agent_id`**. Recommandation : `AppAgentResumer` détient un `Arc>>` (`ResumeContext = { project: Project, rows: u16, cols: u16 }`) **alimenté par le chemin de lancement** (commande `launch_agent`, l.1140, où `project`/`rows`/`cols` sont en main) et lu au moment du resume. Le `mcp_runtime` est **recalculé** dans `resume` à partir de `project.id` via `crate::mcp_endpoint::{idea_exe_path, mcp_endpoint}` (recette identique à l.1126-1133). Évite un scan coûteux de `project_store.list_projects` + manifestes. `store_port` reste injecté en repli (résolution si le contexte est absent après un restart — cohérent avec « état en mémoire only » : après restart c'est `ListResumableAgents` qui reprend, pas le scheduler). Dépendances capturées par l'adapter : `Arc`, `Arc` (repli), le registre `ResumeContext` partagé. --- ## 3. Tap détection niveau 1 (structuré) **Fichier : `crates/app-tauri/src/commands.rs`, fn `agent_send`, boucle de pump l.1283-1294.** C'est le seul drain actif d'un `ReplyStream` structuré (`for event in stream { ... }`). Aujourd'hui `chunk_from_event` (`crates/app-tauri/src/chat.rs:186-193`) **mappe `ReplyEvent::RateLimited` → None** (jeté). Le tap : **avant** d'appeler `chunk_from_event`, faire un `if let ReplyEvent::RateLimited { resets_at_ms } = &event { service.on_rate_limited(agent_id, node_id, conversation_id, *resets_at_ms); }`, puis continuer le drain normalement (l'event reste non-terminal, le tour continue jusqu'au `Final`). **Récupération de `node_id` + `agent_id`** : le pump ne connaît que `sid: SessionId`. Le registre `state.structured_sessions` (`crates/application/src/terminal/registry.rs:297`) mappe `SessionId → (agent_id, node_id)` — mais il manque un accès **par session_id**. Ajouter une petite méthode `meta_for_session(&SessionId) -> Option<(AgentId, NodeId)>` sur `StructuredSessions` (jumeau trivial de `live_agents` l.388, lookup direct dans `entries`). `conversation_id` : `StructuredEntry` ne le porte pas ; passer `None` (le resume dégrade proprement sans id, contrat `ScheduledTask.conversation_id: Option`) **ou**, plus précis, le lire via `providers.json` (passerelle `AppProviderSessionProvider`) — optionnel, `None` est acceptable pour LS7. **État actuel : ce tap est DORMANT.** En composition B-2 la fabrique structurée est décâblée (`launch_agent` retombe toujours sur PTY, l.645-653 ; orchestrateur sans `.with_structured`, l.947-958). Aucun agent n'a de session structurée vivante ⇒ `agent_send` n'est jamais drainé. Le câbler quand même = robustesse forward (réactivation structurée). La détection réelle aujourd'hui passe **exclusivement par le niveau 2**. Passage du service au pump : `agent_send` a `state: State` ⇒ `Arc::clone(&state.session_limit_service)` avant le `thread::spawn` (l.1282) et le *move* dans le thread. --- ## 4. Tap détection niveau 2 (PTY) **Fichier : `crates/app-tauri/src/commands.rs`, fn `launch_agent`, branche PTY l.1165-1190** (le `if output.structured.is_none()` + le `thread::spawn` du pump d'octets l.1176-1183). C'est le drain de la sortie des agents non-structurés — donc le chemin **actif** en B-2. Câblage : 1. **Sélection (anti-double-détection §21.10-4)** : avant d'armer un parser, résoudre le `AgentProfile` de l'agent et appeler `infrastructure::ratelimit::applies(&profile)`. `applies` impose déjà `structured_adapter.is_none()` (or on est dans la branche `output.structured.is_none()` ⇒ cohérent) **et** `rate_limit_pattern.is_some()`. **Besoin de wiring** : la commande `launch_agent` ne charge pas le profil. Deux options — (a) exposer le `AgentProfile` (ou au minimum le `RateLimitPattern`) résolu sur `LaunchAgentOutput` (`LaunchAgent::execute` le résout déjà en interne — le plus propre, zéro I/O en plus) ; (b) le relire via le profile store. Recommandation : (a). 2. **Instanciation** : `RateLimitParser::new(&pattern)` (retourne `Option` ⇒ regex invalide = pas de détecteur, jamais de panique). À construire **une fois par lancement**, déplacé dans le thread de pump. 3. **Alimentation** : dans la boucle `for chunk in stream` (l.1177), après `send_output`, décoder le fragment (`String::from_utf8_lossy`) et appeler `parser.detect(&text, clock.now_millis())`. Sur `Some(SessionLimit)` ⇒ `service.on_rate_limited(agent_id, node_id, conversation_id, limit.resets_at_ms)`. Ici `agent_id`, `node_id` (l.1115) et `conversation_id` (`request.conversation_id`, l.1150) sont **déjà en main** dans la commande ⇒ les cloner avant le `thread::spawn`. Idem `Arc::clone(&state.session_limit_service)` et un `Arc` (ajouter un champ `clock` à `AppState`, ou réutiliser `SystemMillisClock`). **Anti-double-détection** : garantie par construction — `applies` ne renvoie `true` que pour les agents **sans** adapter structuré ; un agent structuré (N1) n'arme jamais de parser N2. Un seul tap actif par agent. **Fragilité fragmentation** : `detect` reçoit des fragments PTY ; un motif peut être coupé entre deux chunks. Pour LS7, accepter la détection best-effort par fragment (les bannières de limite des CLI arrivent en général d'un bloc). Si QA observe des ratés, prévoir un petit buffer glissant borné (dernières ~4 Kio) côté thread — **note pour QA, pas bloquant**. --- ## 5. Drain du Scheduler `TokioScheduler` (`crates/infrastructure/src/scheduler/mod.rs:62-85`) pousse la `ScheduledTask` échue dans son `tx` (canal `mpsc` non borné). Le récepteur `resume_rx` (créé en §1) est **drainé dans une tâche détachée spawné à l'intérieur de `AppState::build`**, sur le **patron exact** du sweeper `sweep_stalled` (`crates/app-tauri/src/state.rs:899-911`) — `tauri::async_runtime::spawn` (et **pas** `tokio::spawn` : `build` tourne dans le hook `setup` sans runtime ambiant, cf. commentaire l.901-903). Boucle : `while let Some(task) = resume_rx.recv().await { if let Err(e) = service.execute_resume(task).await { /* log best-effort */ } }`. Le `Arc` et `resume_rx` sont *moved* dans la closure. `execute_resume` désarme l'entrée puis appelle `AgentResumer::resume` puis publie `AgentResumed` (déjà relayé par LS6). La tâche vit autant que l'app (le canal se ferme au drop du `TokioScheduler`/AppState). --- ## 6. Commande Tauri `cancel_resume` **Déclaration** : `crates/app-tauri/src/commands.rs` — nouvelle `#[tauri::command] 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))`. (`parse_agent_id` existe déjà, cf. l.1111/1215.) Retourne le `bool` (true = reprise effectivement annulée ; false = rien d'armé ou « cancel pile au tir » — voir doc service l.188-213). **Enregistrement** : `crates/app-tauri/src/lib.rs`, dans `tauri::generate_handler![ … ]` (l.122) — ajouter `commands::cancel_resume,` à côté des commandes agent (p.ex. après `commands::change_agent_profile`, l.166). LS8 (UI fenêtre annulable) l'appellera via `invoke("cancel_resume", { agentId })`. --- ## Ordre d'implémentation recommandé 1. **Service + scheduler + drain** (§1 + §5) avec un `AgentResumer` *stub* (renvoie `Ok(())`) ⇒ valide que `on_rate_limited` arme, que le canal remet la tâche, que `execute_resume`/`AgentResumed` partent. Testable sans toucher aux pumps. 2. **`cancel_resume`** (§6) ⇒ boucle détecter→annuler bout-en-bout vérifiable manuellement (events au front via LS6). 3. **Tap N2 PTY** (§4) — chemin actif ⇒ première vraie détection. Implique d'exposer le `RateLimitPattern`/`AgentProfile` sur `LaunchAgentOutput`. 4. **`AppAgentResumer` réel** (§2) + registre `ResumeContext` alimenté par `launch_agent` ⇒ reprise réelle. 5. **Tap N1 structuré** (§3) + `meta_for_session` ⇒ forward-compat (dormant), à câbler en dernier. ## Points de fragilité / race pour QA - **Résolution `agent_id → Project` au resume** (§2) : le gros risque. Vérifier qu'un resume après fermeture/réouverture de projet ne plante pas (repli `None`/erreur propre, jamais de panique). Tester resume quand le `ResumeContext` est absent. - **Cancel « pile au tir »** (service l.188-213, scheduler `cancel` l.87-100) : sous runtime multi-thread, `cancel` peut renvoyer `false` car la tâche vient de tirer ⇒ **pas** d'`AgentResumeCancelled`, la reprise suit son cours. UI LS8 doit tolérer un `false` (la reprise arrive quand même). Test de course explicite recommandé. - **Rafraîchissement (dédoublonnage §21.10-4)** : deux signaux de limite rapprochés pour le même agent ⇒ `disarm` puis ré-arm ; vérifier qu'on n'empile pas deux `ScheduledTask` et qu'un seul `AgentResumed` sort. - **Fragmentation PTY** (§4) : motif coupé entre chunks (best-effort en LS7, buffer glissant en repli si raté). - **Injection du `resume_prompt` en PTY** (§2) : timing d'écriture après spawn (la CLI doit être prête) — réutiliser le portail d'écriture médié plutôt qu'un write brut. - **Premier tour différé / cold-start** : interaction avec `release_agent_cold_start` (l.1132-1137) si le resume relance un agent dont le pont MCP n'est pas encore connecté. ## Conformité hexagonale — confirmée - **Domaine pur** : `SessionLimit`/`plan_resume`/`RateLimitPattern`/`ScheduledTask`/`Scheduler`/`Clock`/`EventBus` ne portent aucune I/O ni regex. ✅ - **Regex + parsing d'heure confinés infra** : `RateLimitParser` et `timeparse` vivent dans `crates/infrastructure/src/ratelimit|timeparse` ; ils produisent une **valeur domaine** (`SessionLimit`). Aucune `regex` ne franchit la frontière. ✅ - **Service applicatif pur-ports** : `SessionLimitService` ne dépend que de traits (`Clock`/`Scheduler`/`EventBus`/`AgentResumer`). ✅ - **Composition root seul à connaître le concret** : `TokioScheduler`, `RateLimitParser`, `AppAgentResumer`, le canal `mpsc` et les taps sont **tous** dans `app-tauri` (state.rs/commands.rs/lib.rs), jamais ailleurs. `AgentResumer` est un port applicatif implémenté à la racine, exactement comme `HandoffProvider`/`ProviderSessionProvider`. ✅ Aucun nouveau port domaine, aucun nouvel adapter infra : LS7 est purement du **câblage** (composition de l'existant), conforme à ARCHITECTURE §21 et au principe « zéro nouveau port/adapter » du lot. - **Prompt:** Arbitrage de scope sur le filet humain NIVEAU 3 de la feature limites de session (ARCHITECTURE §21.1 / §21 « niveau 3 »). Contexte : backend (commit 9df5923) + front (en cours, vert) de la gestion des limites de session sont posés. Niveaux 1 (structuré) et 2 (regex PTY) : pleinement fonctionnels (détecter→planifier reprise auto annulable→reprendre). Le front consomme les 5 DOMAIN_EVENT, affiche le badge « limité jusqu'à HH:MM », le compte à rebours + bouton « Annuler la reprise », et appelle la commande Tauri `cancel_resume`. GAP constaté sur le niveau 3 : l'événement `agentRateLimitSuspected` (limite suspectée SANS heure de reset fiable) est bien émis et affiché côté front (note « limité · heure inconnue — reprise à préciser »), MAIS il n'existe AUCUNE commande backend pour que l'utilisateur SAISISSE l'heure de reprise et déclenche une reprise auto à cet instant. DevFrontend a (correctement) refusé de fabriquer une commande inexistante et a posé un TODO. Donc aujourd'hui, sur niveau 3, l'utilisateur est INFORMÉ mais ne peut pas AGIR via le mécanisme de reprise auto — il lui reste seulement le relancement manuel de l'agent par les contrôles existants. Le design verrouillé (mémoire session-limit-handling-design) dit : niveau 3 = « filet humain : IdeA DEMANDE à l'utilisateur. Garantit le 100% même pour un novice : jamais d'inaction silencieuse » ; et « Couverture : les TROIS niveaux d'emblée ». QUESTION (tranche, c'est ton domaine — contrat/frontière) : A) Le niveau 3 est-il considéré COMPLET en l'état (surfacer la suspicion + s'appuyer sur le relancement manuel existant satisfait « jamais d'inaction silencieuse ») → alors la feature est terminée, on merge. OU B) Le niveau 3 EXIGE une boucle d'action : une commande applicative type `set_resume_at(agentId, epochMs)` qui arme le même `SessionLimitService` (planifier une reprise à l'instant saisi, annulable comme les autres) + un formulaire de saisie d'heure côté front. → alors c'est une slice supplémentaire LSx (domaine? non / application: méthode service + port? / app-tauri: commande / front: form) que je dois cadrer avant le merge. Si B), précise le découpage minimal et le contrat exact de la nouvelle entrée (signature commande, où elle branche dans SessionLimitService, événements émis — réutilise-t-on AgentResumeScheduled ?). Réponds de façon décisionnelle. -- **Response:** VERDICT : **B**. Le niveau 3 n'est PAS complet en l'état. Une slice supplémentaire (LS8) est requise AVANT le merge. Le découpage et le contrat exact suivent. ## Pourquoi B (et pas A) Le design verrouillé est sans ambiguïté : niveau 3 = « IdeA **DEMANDE** à l'utilisateur » et « les TROIS niveaux d'emblée ». « Demander » est un acte qui **arme** une reprise, pas une note d'information. La preuve est dans le code lui-même : 1. `domain::session_limit::RateLimitSource::Human` existe déjà, documenté « niveau 3 : confirmée par l'utilisateur », et n'est **produit nulle part** aujourd'hui. C'est du code mort qui attend exactement cette entrée. L'archi a toujours prévu B. 2. La doc de `ResumePlan::HumanFallback` dit : « L'application **demandera l'heure** à l'utilisateur plutôt que d'agir à l'aveugle ». La boucle d'action fait partie du contrat domaine, pas une extension. Surfacer + relancement manuel (chemin `ListResumableAgents`) ne satisfait PAS « jamais d'inaction silencieuse » au sens de la feature : c'est un **autre mécanisme**, qui ne donne ni la reprise auto programmée ni la fenêtre **annulable** que les niveaux 1/2 garantissent. Sur niveau 3, l'utilisateur est aujourd'hui informé mais le mécanisme central de la feature lui est inaccessible. Incohérence de contrat ⇒ non mergeable tel quel. DevFrontend a eu raison de poser le TODO plutôt que d'inventer la commande. ## Découpage minimal — LS8 « filet humain : armement par heure saisie » **Domaine : RIEN à ajouter.** `plan_resume`, `SessionLimit`, `RateLimitSource::Human`, `ResumePlan::Scheduled` couvrent déjà tout. Une heure saisie par l'utilisateur est fonctionnellement une `SessionLimit` de source `Human` avec `resets_at_ms = Some(epoch)`. Le clamp anti-passé (`max(now)`) protège déjà une saisie déjà échue ⇒ reprise immédiate. C'est le payoff de l'hexagonal : zéro nouveau port, zéro nouvel adapter. **Application — `SessionLimitService` : une méthode publique.** N'élargis PAS `on_rate_limited` (sémantique « signal de détection auto »). Ajoute une entrée dédiée, en réutilisant strictement la branche `Scheduled` existante : ```rust /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset /// pour un agent en limite SUSPECTÉE (AgentRateLimitSuspected, sans heure fiable). /// Construit une SessionLimit source `Human`, calcule le plan et arme la reprise /// EXACTEMENT comme la branche auto : mêmes événements, même dédoublonnage, /// même annulabilité via cancel_resume. pub fn confirm_human_resume( &self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64, // i64 nu, pas Option : la saisie EST l'heure ) ``` Corps = copie de la branche `ResumePlan::Scheduled` de `on_rate_limited` (publish `AgentRateLimited{Some}` → `disarm` → `scheduler.arm(ResumeAgent)` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled`), avec `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)`. **Factorise** la branche en un `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` privé appelé par les deux chemins, pour ne pas dupliquer le dédoublonnage. `execute_resume` et `cancel_resume` restent **inchangés** : l'armement humain est annulable et s'exécute par les mêmes voies (c'est l'invariant à préserver — un seul mécanisme de reprise). **app-tauri — une commande.** Miroir exact de `cancel_resume` : ```rust #[tauri::command] pub async fn set_resume_at( agent_id: String, resets_at_ms: i64, state: State<'_, AppState>, ) -> Result<(), ErrorDto> ``` Corps : `parse_agent_id` → résoudre `node_id` et `conversation_id` **côté backend** depuis la registry (le front n'a que l'`agent_id` ; `agentRateLimitSuspected` ne porte que ça) : - `node_id` via `TerminalSessions::node_for_agent(&id)` (ou la registry unifiée). Si `None` ⇒ `ErrorDto` INVALID/NOT_FOUND (l'agent n'a plus de cellule vivante — la saisie n'a pas de cible). - `conversation_id` via la session structurée `session_for_agent(&id).map(|s| s.conversation_id())` (best-effort `None` toléré, comme tout le chemin de reprise dégradé). Puis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`. **Front — un formulaire minimal.** Sur le badge « limité · heure inconnue — reprise à préciser » : un petit input heure (ou datetime) → calcule l'epoch ms → `invoke("set_resume_at", { agentId, resetsAtMs })`. Dès réception de `AgentResumeScheduled` (déjà consommé !), le badge bascule **automatiquement** sur l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton « Annuler la reprise » déjà câblés. Aucun nouvel événement, aucune nouvelle vue d'état côté front : la boucle se referme sur l'UI existante. ## Contrat des événements — RÉUTILISE l'existant, n'en crée AUCUN C'est le point clé de cohérence : `confirm_human_resume` émet la **même** paire que la branche auto — `AgentRateLimited { agent_id, resets_at_ms: Some(t) }` puis **`AgentResumeScheduled { agent_id, fire_at_ms }`**. Le front les consomme déjà. La transition « suspecté → programmé » se fait donc sans code de présentation neuf : `AgentResumeScheduled` est le pivot qui retire l'état « heure inconnue » et réutilise le rendu nominal. Annulation ⇒ `AgentResumeCancelled` (inchangé). Reprise ⇒ `AgentResumed` (inchangé). `AgentRateLimitSuspected` reste l'unique signal « il faut demander », rien d'autre. ## Garde-fous QA pour le binôme - Saisie dans le passé : pas un cas d'erreur — `plan_resume` clampe à `now` ⇒ reprise quasi-immédiate. Test à ajouter. - Re-saisie / second suspect : `confirm_human_resume` doit passer par le **même `disarm`** que l'auto ⇒ un seul armement par agent (§21.10-4). Test de dédoublonnage croisé (humain après auto, et inverse). - Agent disparu entre suspicion et saisie : `node_for_agent == None` ⇒ erreur propre, pas d'armement orphelin. - Course cancel-pile-au-tir : inchangée, déjà couverte par `cancel_resume`. Périmètre total : ~1 méthode service (factorisée) + 1 commande + 1 input front + tests. Aucune frontière nouvelle, aucun port, aucun adapter. À cadrer et livrer en LS8 avant que Git n'envisage le merge `feature/* → develop`. \ No newline at end of file +- **Response:** VERDICT : **B**. Le niveau 3 n'est PAS complet en l'état. Une slice supplémentaire (LS8) est requise AVANT le merge. Le découpage et le contrat exact suivent. ## Pourquoi B (et pas A) Le design verrouillé est sans ambiguïté : niveau 3 = « IdeA **DEMANDE** à l'utilisateur » et « les TROIS niveaux d'emblée ». « Demander » est un acte qui **arme** une reprise, pas une note d'information. La preuve est dans le code lui-même : 1. `domain::session_limit::RateLimitSource::Human` existe déjà, documenté « niveau 3 : confirmée par l'utilisateur », et n'est **produit nulle part** aujourd'hui. C'est du code mort qui attend exactement cette entrée. L'archi a toujours prévu B. 2. La doc de `ResumePlan::HumanFallback` dit : « L'application **demandera l'heure** à l'utilisateur plutôt que d'agir à l'aveugle ». La boucle d'action fait partie du contrat domaine, pas une extension. Surfacer + relancement manuel (chemin `ListResumableAgents`) ne satisfait PAS « jamais d'inaction silencieuse » au sens de la feature : c'est un **autre mécanisme**, qui ne donne ni la reprise auto programmée ni la fenêtre **annulable** que les niveaux 1/2 garantissent. Sur niveau 3, l'utilisateur est aujourd'hui informé mais le mécanisme central de la feature lui est inaccessible. Incohérence de contrat ⇒ non mergeable tel quel. DevFrontend a eu raison de poser le TODO plutôt que d'inventer la commande. ## Découpage minimal — LS8 « filet humain : armement par heure saisie » **Domaine : RIEN à ajouter.** `plan_resume`, `SessionLimit`, `RateLimitSource::Human`, `ResumePlan::Scheduled` couvrent déjà tout. Une heure saisie par l'utilisateur est fonctionnellement une `SessionLimit` de source `Human` avec `resets_at_ms = Some(epoch)`. Le clamp anti-passé (`max(now)`) protège déjà une saisie déjà échue ⇒ reprise immédiate. C'est le payoff de l'hexagonal : zéro nouveau port, zéro nouvel adapter. **Application — `SessionLimitService` : une méthode publique.** N'élargis PAS `on_rate_limited` (sémantique « signal de détection auto »). Ajoute une entrée dédiée, en réutilisant strictement la branche `Scheduled` existante : ```rust /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset /// pour un agent en limite SUSPECTÉE (AgentRateLimitSuspected, sans heure fiable). /// Construit une SessionLimit source `Human`, calcule le plan et arme la reprise /// EXACTEMENT comme la branche auto : mêmes événements, même dédoublonnage, /// même annulabilité via cancel_resume. pub fn confirm_human_resume( &self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64, // i64 nu, pas Option : la saisie EST l'heure ) ``` Corps = copie de la branche `ResumePlan::Scheduled` de `on_rate_limited` (publish `AgentRateLimited{Some}` → `disarm` → `scheduler.arm(ResumeAgent)` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled`), avec `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)`. **Factorise** la branche en un `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` privé appelé par les deux chemins, pour ne pas dupliquer le dédoublonnage. `execute_resume` et `cancel_resume` restent **inchangés** : l'armement humain est annulable et s'exécute par les mêmes voies (c'est l'invariant à préserver — un seul mécanisme de reprise). **app-tauri — une commande.** Miroir exact de `cancel_resume` : ```rust #[tauri::command] pub async fn set_resume_at( agent_id: String, resets_at_ms: i64, state: State<'_, AppState>, ) -> Result<(), ErrorDto> ``` Corps : `parse_agent_id` → résoudre `node_id` et `conversation_id` **côté backend** depuis la registry (le front n'a que l'`agent_id` ; `agentRateLimitSuspected` ne porte que ça) : - `node_id` via `TerminalSessions::node_for_agent(&id)` (ou la registry unifiée). Si `None` ⇒ `ErrorDto` INVALID/NOT_FOUND (l'agent n'a plus de cellule vivante — la saisie n'a pas de cible). - `conversation_id` via la session structurée `session_for_agent(&id).map(|s| s.conversation_id())` (best-effort `None` toléré, comme tout le chemin de reprise dégradé). Puis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`. **Front — un formulaire minimal.** Sur le badge « limité · heure inconnue — reprise à préciser » : un petit input heure (ou datetime) → calcule l'epoch ms → `invoke("set_resume_at", { agentId, resetsAtMs })`. Dès réception de `AgentResumeScheduled` (déjà consommé !), le badge bascule **automatiquement** sur l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton « Annuler la reprise » déjà câblés. Aucun nouvel événement, aucune nouvelle vue d'état côté front : la boucle se referme sur l'UI existante. ## Contrat des événements — RÉUTILISE l'existant, n'en crée AUCUN C'est le point clé de cohérence : `confirm_human_resume` émet la **même** paire que la branche auto — `AgentRateLimited { agent_id, resets_at_ms: Some(t) }` puis **`AgentResumeScheduled { agent_id, fire_at_ms }`**. Le front les consomme déjà. La transition « suspecté → programmé » se fait donc sans code de présentation neuf : `AgentResumeScheduled` est le pivot qui retire l'état « heure inconnue » et réutilise le rendu nominal. Annulation ⇒ `AgentResumeCancelled` (inchangé). Reprise ⇒ `AgentResumed` (inchangé). `AgentRateLimitSuspected` reste l'unique signal « il faut demander », rien d'autre. ## Garde-fous QA pour le binôme - Saisie dans le passé : pas un cas d'erreur — `plan_resume` clampe à `now` ⇒ reprise quasi-immédiate. Test à ajouter. - Re-saisie / second suspect : `confirm_human_resume` doit passer par le **même `disarm`** que l'auto ⇒ un seul armement par agent (§21.10-4). Test de dédoublonnage croisé (humain après auto, et inverse). - Agent disparu entre suspicion et saisie : `node_for_agent == None` ⇒ erreur propre, pas d'armement orphelin. - Course cancel-pile-au-tir : inchangée, déjà couverte par `cancel_resume`. Périmètre total : ~1 méthode service (factorisée) + 1 commande + 1 input front + tests. Aucune frontière nouvelle, aucun port, aucun adapter. À cadrer et livrer en LS8 avant que Git n'envisage le merge `feature/* → develop`. +- **Prompt:** Diagnostic architecture demandé par Main. Contexte: IdeA intègre Codex CLI. Le profil Codex actuel utilise McpConfigStrategy::TomlConfigHome { target: ".codex/config.toml", home_env: "CODEX_HOME" }, donc au lancement IdeA écrit {runDir}/.codex/config.toml et pousse CODEX_HOME vers ce dossier pour isoler MCP/permissions par agent. Problème produit: chaque agent Codex redemande une connexion ChatGPT/OpenAI, et si l’utilisateur crée beaucoup d’agents il atteint une limite de requests/login. De plus un agent jamais lancé peut échouer lors d’une délégation car il n’est pas authentifié. Questions: 1) quelle solution respecte l’architecture hexagonale/SOLID pour partager l’auth Codex au niveau ordinateur/utilisateur tout en gardant la config MCP/permissions isolée par agent ? 2) faut-il introduire un port Auth/RuntimeHealth/Readiness pour préflight au moment de création/configuration d’agent ? 3) quels changements concrets recommandes-tu dans le domaine/application/infrastructure ? Merci de répondre en français, concis mais actionnable. +- **Prompt:** Cadrage architecture obligatoire avant implémentation. Contexte produit : nous voulons ajouter à IdeA une gestion automatique de la croissance des conversations d'agents afin de réduire les coûts tokens. Le système existant a déjà : log canonique `.ideai/conversations/.../log.jsonl`, handoff incrémental `handoff.md`, injection du handoff au lancement/relaunch, conversation_id logique de paire IdeA, provider session store, reprise session provider quand possible. Besoin : mettre en place une compression/rotation automatique des conversations d'agents, mais avec stabilité maximale. Contraintes fortes validées avec l'utilisateur : - Ne jamais couper un agent au milieu du développement d'une feature. - Ne jamais interrompre un tour en cours, outil en cours, terminal occupé, délégation pendante, ou attente de `idea_reply`. - Ne pas casser les contacts inter-agents : les messages doivent cibler l'identité logique AgentId/ConversationId, pas une session physique instable. - Si un autre agent essaie de contacter un agent en rotation, le routage doit être stable : queue, attente ou ancienne session jusqu'à bascule sûre. - La rotation doit être opportuniste, non interruptive : au seuil normal, préparer/planifier au prochain point sûr ; seuil critique = signaler/attendre point sûr, pas tuer au milieu. - Attention au detach/attach de l'agent sur la cellule : la cellule est une vue, pas la session. - Ne jamais faire de rotation automatique si l'utilisateur est en train d'écrire dans la cellule : focus, buffer d'entrée non envoyé, frappe récente, IME/composition, prompt interactif si détectable. - Nouvelle session idéalement démarrée détachée/background, vérifiée prête, puis bascule atomique du routage + attachement cellule. - Checkpoint durable obligatoire avant bascule : log canonique append OK + handoff à jour. Demande : 1. Lire l'architecture et le code existant autour agent lifecycle, terminal sessions, orchestrator routing, conversation log/handoff, provider sessions, layout/cell attach. 2. Proposer un design hexagonal/SOLID précis : domaine, ports éventuels, use cases, adapters, events, UI state. 3. Découper en lots implémentables DevBackend/DevFrontend/QA, avec tests attendus. 4. Identifier les pièces déjà existantes à réutiliser et les risques. 5. Ne pas coder. Rends un rapport utilisable directement par DevBackend/DevFrontend/QA. +- **Prompt:** Contexte bug IdeA: une délégation inter-agent longue envoyée à un agent Codex TUI arrive visiblement tronquée/partielle dans le terminal cible. Code actuel: frontend `frontend/src/features/terminals/useWritePortal.ts` écrit `head.text` en un seul `handle.write(encoder.encode(head.text))`, attend `submitDelayMs` puis écrit `\r`; chemin headless `crates/infrastructure/src/input/mod.rs::make_headless_sink` écrit aussi `text.as_bytes()` d'un coup puis submit. `makeTerminalHandle` sérialise déjà les invokes et `PortablePtyAdapter::write` fait `write_all+flush`. Hypothèse: le TUI/paste handling ou IPC/PTY avec gros bloc peut perdre/ignorer une fin avant Enter. Proposition de correction locale: introduire un envoi chunké pour les injections de délégation (front et headless), avec chunks UTF-8 safe, pause courte entre chunks, puis délai anti-paste existant avant submit; ack seulement après tous les chunks + submit. Ajouter tests `useWritePortal` qui vérifient texte long chunké en ordre et ack après submit; tests infra input headless si simple. Est-ce compatible avec l'architecture actuelle (single writer, submit config, hexagonal), et vois-tu un meilleur point de correction ? Réponds court avec risques/contraintes. +- **Prompt:** Validation rapide architecture pour correctif IdeA: le runtime structuré Codex lance `codex exec` depuis le run dir isolé et doit permettre l’écriture dans le project root quand les permissions résolues donnent workspace-write. Proposition: faire transiter `PreparedContext.project_root`/équivalent depuis `StructuredSessionFactory::start` vers `CodexExecSession`, puis ajouter `--add-dir ` à la ligne `codex exec` en workspace-write, sans ajouter `--ask-for-approval` car `codex exec` le refuse. Est-ce conforme Ports & Adapters/hexagonal ? Signale toute frontière à ne pas franchir. +- **Response:** Oui, c’est conforme à l’architecture Ports & Adapters, avec une réserve de frontière importante. Validation : - Faire porter `project_root` dans un DTO/VO de contexte préparé (`PreparedContext.project_root` ou équivalent) est acceptable si cette donnée représente le périmètre métier d’exécution du projet, pas un détail Tauri/Codex. - `StructuredSessionFactory::start` peut transmettre cette donnée vers l’adapter runtime concret : c’est précisément le rôle de la couche application/composition d’orchestrer les ports avec le contexte résolu. - Ajouter `--add-dir ` dans `CodexExecSession` est au bon endroit si `CodexExecSession` est l’adapter infrastructure/presentation-backend spécifique à Codex CLI. Le flag `--add-dir` est un détail technique de l’adapter Codex, donc il ne doit pas remonter dans le domaine. - Conditionner cet ajout à `workspace-write` est cohérent : l’intention métier est “la session peut écrire dans le workspace résolu”, l’implémentation Codex est “ajouter le root autorisé à la sandbox CLI”. - Ne pas ajouter `--ask-for-approval` est correct si `codex exec` le refuse. Il ne faut pas forcer une option non supportée au niveau adapter. Frontières à ne pas franchir : - Ne pas faire dépendre le domaine/application de `codex exec`, `--add-dir`, `--sandbox`, ni des noms de flags Codex. Ces détails restent dans l’adapter Codex. - Ne pas laisser `CodexExecSession` recalculer seul le project root à partir du run dir isolé si le project root a déjà été résolu par les couches supérieures. L’adapter doit consommer un contexte préparé, pas réinterpréter l’état global. - Ne pas élargir systématiquement les permissions : `--add-dir` doit être ajouté uniquement pour le `projectRoot` validé/résolu du projet et uniquement quand le mode effectif est `workspace-write`. - Ne pas confondre `cwd` et périmètre d’écriture : le cwd peut rester le run dir isolé pour l’exécution/session, tandis que `project_root` devient une racine autorisée explicite. - Ne pas exposer ce comportement côté React/Tauri commands comme une option utilisateur brute. L’UI demande un lancement d’agent/session ; la politique de sandbox reste dans les services/adapters backend. Formulation recommandée côté modèle : plutôt qu’un champ trop Codex-spécifique, utiliser un nom neutre comme `workspace_roots`, `writable_roots`, `project_root`, ou `ExecutionScope { cwd, project_root, sandbox_mode }`. Puis l’adapter Codex traduit `ExecutionScope + workspace-write` en `codex exec --add-dir `. +- **Prompt:** Contexte: l'utilisateur veut terminer les chantiers ouverts d'IdeA, en autonomie, via le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas. Decision Git deja obtenue: - Branche courante: `feature/orchestrator-designation`. - Worktree dirty important (~75 fichiers) mais analyse Git: mono-theme = chantier orchestrateur/designation + nouveau sink diagnostic `crates/application/src/diag.rs` pour le rendez-vous inter-agents. - Ne pas stash/switch. Premier chantier logique: finir `orchestrator-designation` sur cette branche, puis QA, puis Git commit/merge develop. Etat memoire projet pertinent: - Chantiers restants: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte a chaud, UX delegations/conversations, documentation architecture. - Le chantier actuel semble etre une brique d'orchestration/designation, avec modifications dans domain/application/infrastructure/app-tauri/frontend terminal write portal et permissions/sandbox/session structured. Ta mission Architect: 1. Cadre le chantier courant `orchestrator-designation` a partir de son nom et de l'etat Git ci-dessus: objectif architectural probable, frontieres de couches, contrats/ports/DTO a verifier, invariants attendus. 2. Donne a Main un plan de delegation concret: ce que DevBackend doit verifier/finaliser, ce que DevFrontend doit verifier/finaliser, ce que QA doit tester. 3. Donne des criteres de fin stricts pour considerer ce chantier ferme. 4. Indique s'il faut mettre a jour `architect.md` maintenant ou attendre la fin de la stabilisation. Ne code pas. Reponds avec `idea_reply` si cette tache est deleguee via IdeA. \ No newline at end of file diff --git a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl index df7fa2c..271a443 100644 --- a/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl +++ b/.ideai/conversations/7a2f446c-2efa-0c56-08dd-07cdc5cac19b/log.jsonl @@ -21,3 +21,9 @@ {"id":"1a6e2278-b14d-4b7e-a485-f839bd04ff54","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781639785321,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"# LS7 — Carte de câblage « session-limits » au composition root\n\nTous les chemins sont relatifs au project root. Aucun code ici : seulement les points d'ancrage exacts (fichier · struct/fn · ligne).\n\nFaits vérifiés en amont :\n- Service : `crates/application/src/agent/session_limit.rs` — `SessionLimitService::new(clock, scheduler, events, resumer)` ; API publique : `on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms)`, `execute_resume(task).await -> Result<(),AppError>`, `cancel_resume(agent_id) -> bool`. Port à implémenter : `AgentResumer::resume(agent_id, node_id, conversation_id, resume_prompt)` (async).\n- Scheduler : `crates/infrastructure/src/scheduler/mod.rs` — `TokioScheduler::new(tx: UnboundedSender, clock: Arc)`. Réexporté `infrastructure::TokioScheduler`. Il **pousse** la `ScheduledTask` échue dans `tx` ; le drain détient `rx`.\n- Parser N2 : `crates/infrastructure/src/ratelimit/mod.rs` — `RateLimitParser::new(&RateLimitPattern) -> Option`, `detect(text, now_ms) -> Option`, et la règle de sélection `ratelimit::applies(profile) -> bool` (= `structured_adapter.is_none() && rate_limit_pattern.is_some()`). Réexportés `infrastructure::RateLimitParser` / `infrastructure::ratelimit::applies`.\n- LS6 a déjà câblé les 6 variantes `DomainEvent::Agent{RateLimited,ResumeScheduled,ResumeCancelled,Resumed,RateLimitSuspected}` → DTO → relay (`crates/app-tauri/src/events.rs:208-253, 426-452`). Rien à faire côté event wire.\n\n---\n\n## 1. Instanciation du service (composition root)\n\n**Fichier : `crates/app-tauri/src/state.rs`, dans `AppState::build` (à partir de la ligne 355).**\n\nRéutiliser les adapters déjà construits en tête de `build` :\n- `clock` (l.358, `SystemClock`, implémente `Clock::now_millis`) → caster en `Arc` comme partout (cf. `Arc::clone(&clock) as Arc`).\n- `event_bus` (l.357) → `events_port` (l.369, `Arc`), le **même** bus partagé que tout le reste (donc l'`AgentRateLimited` émis passera par `spawn_relay`).\n\nSéquence d'instanciation (à placer après la construction de `launch_agent` l.668-700 et de `project_store` l.750, car le resumer en dépend ; idéalement juste avant le bloc orchestrateur l.917) :\n\n1. `let (resume_tx, resume_rx) = tokio::sync::mpsc::unbounded_channel::();`\n2. `let scheduler = Arc::new(TokioScheduler::new(resume_tx, Arc::clone(&clock) as Arc)) as Arc;`\n3. `let resumer = Arc::new(AppAgentResumer::new(Arc::clone(&launch_agent), Arc::clone(&store_port), )) as Arc;` (cf. §2).\n4. `let session_limit_service = Arc::new(SessionLimitService::new(Arc::clone(&clock) as Arc, scheduler, Arc::clone(&events_port), resumer));`\n\n**Cycle de vie / partage :** ajouter un champ `pub session_limit_service: Arc` à la struct `AppState` (déclaration vers l.326, à côté de `orchestrator_service`) et le renvoyer dans le littéral final (l.967-1055). Le `Arc` est partagé par : (a) la commande `cancel_resume` (§6), (b) la tâche de drain (§5), (c) les taps de détection N1/N2 (§3/§4) qui appellent `on_rate_limited`. Le `resume_rx` n'entre **pas** dans `AppState` : il est *moved* dans la tâche de drain spawné à l'intérieur de `build` (§5), exactement comme `sweep_stalled` (l.899-911).\n\nImports à ajouter en tête de `state.rs` : `application::{SessionLimitService, AgentResumer}`, `domain::ports::{Scheduler, ScheduledTask}`, `infrastructure::TokioScheduler`.\n\n---\n\n## 2. Port `AgentResumer` → `LaunchAgent` (adapter app-tauri)\n\n**Nouvel adapter dans `crates/app-tauri/src/state.rs`**, à côté des passerelles stateless existantes `AppHandoffProvider` (l.100-109) / `AppProviderSessionProvider` (l.119-128) / `AppRecordTurnProvider` (l.81-90) — même patron `impl application::Trait for AppXxx`.\n\n`impl application::AgentResumer for AppAgentResumer { async fn resume(agent_id, node_id, conversation_id, resume_prompt) -> Result<(), AppError> }` recompose un `LaunchAgentInput` et appelle `self.launch_agent.execute(...)` (le **même** `Arc` que la commande `launch_agent`, l.1140). C'est `LaunchAgent` qui, via `with_handoff_provider` / `with_provider_session_provider` (l.687-695) et le routage §17.4, applique déjà `SessionPlan::Resume` quand un `conversation_id` est présent (chemin de reprise P7/P8b/§15). Le `resume_prompt` (constante `application::RESUME_PROMPT`) est le premier tour — pour le chemin **PTY natif** (composition B-2, voir l.645-653), il devra être écrit dans le PTY après spawn ; pour le chemin structuré (dormant), passé en premier `send`. **À cadrer dev :** où injecter ce premier tour. La voie la plus cohérente avec l'existant est de réutiliser le médiateur d'entrée (`MediatedInbox`/portail d'écriture PTY, l.883-892) plutôt qu'un write direct.\n\n**⚠️ Point dur de conception (à trancher, c'est LE risque du lot) :** `AgentResumer::resume` et `ScheduledTask::ResumeAgent` ne portent **pas** de `project_id`, alors que `LaunchAgentInput` exige un `Project` complet + `rows`/`cols` + `mcp_runtime` (cf. commande l.1126-1152). Le service est volontairement model/projet-agnostique. Il faut donc que l'adapter **résolve le `Project` à partir de l'`agent_id`**. Recommandation : `AppAgentResumer` détient un `Arc>>` (`ResumeContext = { project: Project, rows: u16, cols: u16 }`) **alimenté par le chemin de lancement** (commande `launch_agent`, l.1140, où `project`/`rows`/`cols` sont en main) et lu au moment du resume. Le `mcp_runtime` est **recalculé** dans `resume` à partir de `project.id` via `crate::mcp_endpoint::{idea_exe_path, mcp_endpoint}` (recette identique à l.1126-1133). Évite un scan coûteux de `project_store.list_projects` + manifestes. `store_port` reste injecté en repli (résolution si le contexte est absent après un restart — cohérent avec « état en mémoire only » : après restart c'est `ListResumableAgents` qui reprend, pas le scheduler).\n\nDépendances capturées par l'adapter : `Arc`, `Arc` (repli), le registre `ResumeContext` partagé.\n\n---\n\n## 3. Tap détection niveau 1 (structuré)\n\n**Fichier : `crates/app-tauri/src/commands.rs`, fn `agent_send`, boucle de pump l.1283-1294.** C'est le seul drain actif d'un `ReplyStream` structuré (`for event in stream { ... }`). Aujourd'hui `chunk_from_event` (`crates/app-tauri/src/chat.rs:186-193`) **mappe `ReplyEvent::RateLimited` → None** (jeté). Le tap : **avant** d'appeler `chunk_from_event`, faire un `if let ReplyEvent::RateLimited { resets_at_ms } = &event { service.on_rate_limited(agent_id, node_id, conversation_id, *resets_at_ms); }`, puis continuer le drain normalement (l'event reste non-terminal, le tour continue jusqu'au `Final`).\n\n**Récupération de `node_id` + `agent_id`** : le pump ne connaît que `sid: SessionId`. Le registre `state.structured_sessions` (`crates/application/src/terminal/registry.rs:297`) mappe `SessionId → (agent_id, node_id)` — mais il manque un accès **par session_id**. Ajouter une petite méthode `meta_for_session(&SessionId) -> Option<(AgentId, NodeId)>` sur `StructuredSessions` (jumeau trivial de `live_agents` l.388, lookup direct dans `entries`). `conversation_id` : `StructuredEntry` ne le porte pas ; passer `None` (le resume dégrade proprement sans id, contrat `ScheduledTask.conversation_id: Option`) **ou**, plus précis, le lire via `providers.json` (passerelle `AppProviderSessionProvider`) — optionnel, `None` est acceptable pour LS7.\n\n**État actuel : ce tap est DORMANT.** En composition B-2 la fabrique structurée est décâblée (`launch_agent` retombe toujours sur PTY, l.645-653 ; orchestrateur sans `.with_structured`, l.947-958). Aucun agent n'a de session structurée vivante ⇒ `agent_send` n'est jamais drainé. Le câbler quand même = robustesse forward (réactivation structurée). La détection réelle aujourd'hui passe **exclusivement par le niveau 2**.\n\nPassage du service au pump : `agent_send` a `state: State` ⇒ `Arc::clone(&state.session_limit_service)` avant le `thread::spawn` (l.1282) et le *move* dans le thread.\n\n---\n\n## 4. Tap détection niveau 2 (PTY)\n\n**Fichier : `crates/app-tauri/src/commands.rs`, fn `launch_agent`, branche PTY l.1165-1190** (le `if output.structured.is_none()` + le `thread::spawn` du pump d'octets l.1176-1183). C'est le drain de la sortie des agents non-structurés — donc le chemin **actif** en B-2.\n\nCâblage :\n1. **Sélection (anti-double-détection §21.10-4)** : avant d'armer un parser, résoudre le `AgentProfile` de l'agent et appeler `infrastructure::ratelimit::applies(&profile)`. `applies` impose déjà `structured_adapter.is_none()` (or on est dans la branche `output.structured.is_none()` ⇒ cohérent) **et** `rate_limit_pattern.is_some()`. **Besoin de wiring** : la commande `launch_agent` ne charge pas le profil. Deux options — (a) exposer le `AgentProfile` (ou au minimum le `RateLimitPattern`) résolu sur `LaunchAgentOutput` (`LaunchAgent::execute` le résout déjà en interne — le plus propre, zéro I/O en plus) ; (b) le relire via le profile store. Recommandation : (a).\n2. **Instanciation** : `RateLimitParser::new(&pattern)` (retourne `Option` ⇒ regex invalide = pas de détecteur, jamais de panique). À construire **une fois par lancement**, déplacé dans le thread de pump.\n3. **Alimentation** : dans la boucle `for chunk in stream` (l.1177), après `send_output`, décoder le fragment (`String::from_utf8_lossy`) et appeler `parser.detect(&text, clock.now_millis())`. Sur `Some(SessionLimit)` ⇒ `service.on_rate_limited(agent_id, node_id, conversation_id, limit.resets_at_ms)`. Ici `agent_id`, `node_id` (l.1115) et `conversation_id` (`request.conversation_id`, l.1150) sont **déjà en main** dans la commande ⇒ les cloner avant le `thread::spawn`. Idem `Arc::clone(&state.session_limit_service)` et un `Arc` (ajouter un champ `clock` à `AppState`, ou réutiliser `SystemMillisClock`).\n\n**Anti-double-détection** : garantie par construction — `applies` ne renvoie `true` que pour les agents **sans** adapter structuré ; un agent structuré (N1) n'arme jamais de parser N2. Un seul tap actif par agent.\n\n**Fragilité fragmentation** : `detect` reçoit des fragments PTY ; un motif peut être coupé entre deux chunks. Pour LS7, accepter la détection best-effort par fragment (les bannières de limite des CLI arrivent en général d'un bloc). Si QA observe des ratés, prévoir un petit buffer glissant borné (dernières ~4 Kio) côté thread — **note pour QA, pas bloquant**.\n\n---\n\n## 5. Drain du Scheduler\n\n`TokioScheduler` (`crates/infrastructure/src/scheduler/mod.rs:62-85`) pousse la `ScheduledTask` échue dans son `tx` (canal `mpsc` non borné). Le récepteur `resume_rx` (créé en §1) est **drainé dans une tâche détachée spawné à l'intérieur de `AppState::build`**, sur le **patron exact** du sweeper `sweep_stalled` (`crates/app-tauri/src/state.rs:899-911`) — `tauri::async_runtime::spawn` (et **pas** `tokio::spawn` : `build` tourne dans le hook `setup` sans runtime ambiant, cf. commentaire l.901-903).\n\nBoucle : `while let Some(task) = resume_rx.recv().await { if let Err(e) = service.execute_resume(task).await { /* log best-effort */ } }`. Le `Arc` et `resume_rx` sont *moved* dans la closure. `execute_resume` désarme l'entrée puis appelle `AgentResumer::resume` puis publie `AgentResumed` (déjà relayé par LS6). La tâche vit autant que l'app (le canal se ferme au drop du `TokioScheduler`/AppState).\n\n---\n\n## 6. Commande Tauri `cancel_resume`\n\n**Déclaration** : `crates/app-tauri/src/commands.rs` — nouvelle `#[tauri::command] 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))`. (`parse_agent_id` existe déjà, cf. l.1111/1215.) Retourne le `bool` (true = reprise effectivement annulée ; false = rien d'armé ou « cancel pile au tir » — voir doc service l.188-213).\n\n**Enregistrement** : `crates/app-tauri/src/lib.rs`, dans `tauri::generate_handler![ … ]` (l.122) — ajouter `commands::cancel_resume,` à côté des commandes agent (p.ex. après `commands::change_agent_profile`, l.166). LS8 (UI fenêtre annulable) l'appellera via `invoke(\"cancel_resume\", { agentId })`.\n\n---\n\n## Ordre d'implémentation recommandé\n\n1. **Service + scheduler + drain** (§1 + §5) avec un `AgentResumer` *stub* (renvoie `Ok(())`) ⇒ valide que `on_rate_limited` arme, que le canal remet la tâche, que `execute_resume`/`AgentResumed` partent. Testable sans toucher aux pumps.\n2. **`cancel_resume`** (§6) ⇒ boucle détecter→annuler bout-en-bout vérifiable manuellement (events au front via LS6).\n3. **Tap N2 PTY** (§4) — chemin actif ⇒ première vraie détection. Implique d'exposer le `RateLimitPattern`/`AgentProfile` sur `LaunchAgentOutput`.\n4. **`AppAgentResumer` réel** (§2) + registre `ResumeContext` alimenté par `launch_agent` ⇒ reprise réelle.\n5. **Tap N1 structuré** (§3) + `meta_for_session` ⇒ forward-compat (dormant), à câbler en dernier.\n\n## Points de fragilité / race pour QA\n\n- **Résolution `agent_id → Project` au resume** (§2) : le gros risque. Vérifier qu'un resume après fermeture/réouverture de projet ne plante pas (repli `None`/erreur propre, jamais de panique). Tester resume quand le `ResumeContext` est absent.\n- **Cancel « pile au tir »** (service l.188-213, scheduler `cancel` l.87-100) : sous runtime multi-thread, `cancel` peut renvoyer `false` car la tâche vient de tirer ⇒ **pas** d'`AgentResumeCancelled`, la reprise suit son cours. UI LS8 doit tolérer un `false` (la reprise arrive quand même). Test de course explicite recommandé.\n- **Rafraîchissement (dédoublonnage §21.10-4)** : deux signaux de limite rapprochés pour le même agent ⇒ `disarm` puis ré-arm ; vérifier qu'on n'empile pas deux `ScheduledTask` et qu'un seul `AgentResumed` sort.\n- **Fragmentation PTY** (§4) : motif coupé entre chunks (best-effort en LS7, buffer glissant en repli si raté).\n- **Injection du `resume_prompt` en PTY** (§2) : timing d'écriture après spawn (la CLI doit être prête) — réutiliser le portail d'écriture médié plutôt qu'un write brut.\n- **Premier tour différé / cold-start** : interaction avec `release_agent_cold_start` (l.1132-1137) si le resume relance un agent dont le pont MCP n'est pas encore connecté.\n\n## Conformité hexagonale — confirmée\n\n- **Domaine pur** : `SessionLimit`/`plan_resume`/`RateLimitPattern`/`ScheduledTask`/`Scheduler`/`Clock`/`EventBus` ne portent aucune I/O ni regex. ✅\n- **Regex + parsing d'heure confinés infra** : `RateLimitParser` et `timeparse` vivent dans `crates/infrastructure/src/ratelimit|timeparse` ; ils produisent une **valeur domaine** (`SessionLimit`). Aucune `regex` ne franchit la frontière. ✅\n- **Service applicatif pur-ports** : `SessionLimitService` ne dépend que de traits (`Clock`/`Scheduler`/`EventBus`/`AgentResumer`). ✅\n- **Composition root seul à connaître le concret** : `TokioScheduler`, `RateLimitParser`, `AppAgentResumer`, le canal `mpsc` et les taps sont **tous** dans `app-tauri` (state.rs/commands.rs/lib.rs), jamais ailleurs. `AgentResumer` est un port applicatif implémenté à la racine, exactement comme `HandoffProvider`/`ProviderSessionProvider`. ✅\n\nAucun nouveau port domaine, aucun nouvel adapter infra : LS7 est purement du **câblage** (composition de l'existant), conforme à ARCHITECTURE §21 et au principe « zéro nouveau port/adapter » du lot."} {"id":"28319a6e-f4ed-4514-8092-834cfac2a4ff","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781676433765,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Arbitrage de scope sur le filet humain NIVEAU 3 de la feature limites de session (ARCHITECTURE §21.1 / §21 « niveau 3 »).\n\nContexte : backend (commit 9df5923) + front (en cours, vert) de la gestion des limites de session sont posés. Niveaux 1 (structuré) et 2 (regex PTY) : pleinement fonctionnels (détecter→planifier reprise auto annulable→reprendre). Le front consomme les 5 DOMAIN_EVENT, affiche le badge « limité jusqu'à HH:MM », le compte à rebours + bouton « Annuler la reprise », et appelle la commande Tauri `cancel_resume`.\n\nGAP constaté sur le niveau 3 : l'événement `agentRateLimitSuspected` (limite suspectée SANS heure de reset fiable) est bien émis et affiché côté front (note « limité · heure inconnue — reprise à préciser »), MAIS il n'existe AUCUNE commande backend pour que l'utilisateur SAISISSE l'heure de reprise et déclenche une reprise auto à cet instant. DevFrontend a (correctement) refusé de fabriquer une commande inexistante et a posé un TODO. Donc aujourd'hui, sur niveau 3, l'utilisateur est INFORMÉ mais ne peut pas AGIR via le mécanisme de reprise auto — il lui reste seulement le relancement manuel de l'agent par les contrôles existants.\n\nLe design verrouillé (mémoire session-limit-handling-design) dit : niveau 3 = « filet humain : IdeA DEMANDE à l'utilisateur. Garantit le 100% même pour un novice : jamais d'inaction silencieuse » ; et « Couverture : les TROIS niveaux d'emblée ».\n\nQUESTION (tranche, c'est ton domaine — contrat/frontière) :\nA) Le niveau 3 est-il considéré COMPLET en l'état (surfacer la suspicion + s'appuyer sur le relancement manuel existant satisfait « jamais d'inaction silencieuse ») → alors la feature est terminée, on merge.\nOU\nB) Le niveau 3 EXIGE une boucle d'action : une commande applicative type `set_resume_at(agentId, epochMs)` qui arme le même `SessionLimitService` (planifier une reprise à l'instant saisi, annulable comme les autres) + un formulaire de saisie d'heure côté front. → alors c'est une slice supplémentaire LSx (domaine? non / application: méthode service + port? / app-tauri: commande / front: form) que je dois cadrer avant le merge.\n\nSi B), précise le découpage minimal et le contrat exact de la nouvelle entrée (signature commande, où elle branche dans SessionLimitService, événements émis — réutilise-t-on AgentResumeScheduled ?). Réponds de façon décisionnelle."} {"id":"267ce6cf-bd44-464d-aea5-c4455652a79e","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781676537981,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"VERDICT : **B**. Le niveau 3 n'est PAS complet en l'état. Une slice supplémentaire (LS8) est requise AVANT le merge. Le découpage et le contrat exact suivent.\n\n## Pourquoi B (et pas A)\n\nLe design verrouillé est sans ambiguïté : niveau 3 = « IdeA **DEMANDE** à l'utilisateur » et « les TROIS niveaux d'emblée ». « Demander » est un acte qui **arme** une reprise, pas une note d'information. La preuve est dans le code lui-même :\n\n1. `domain::session_limit::RateLimitSource::Human` existe déjà, documenté « niveau 3 : confirmée par l'utilisateur », et n'est **produit nulle part** aujourd'hui. C'est du code mort qui attend exactement cette entrée. L'archi a toujours prévu B.\n2. La doc de `ResumePlan::HumanFallback` dit : « L'application **demandera l'heure** à l'utilisateur plutôt que d'agir à l'aveugle ». La boucle d'action fait partie du contrat domaine, pas une extension.\n\nSurfacer + relancement manuel (chemin `ListResumableAgents`) ne satisfait PAS « jamais d'inaction silencieuse » au sens de la feature : c'est un **autre mécanisme**, qui ne donne ni la reprise auto programmée ni la fenêtre **annulable** que les niveaux 1/2 garantissent. Sur niveau 3, l'utilisateur est aujourd'hui informé mais le mécanisme central de la feature lui est inaccessible. Incohérence de contrat ⇒ non mergeable tel quel. DevFrontend a eu raison de poser le TODO plutôt que d'inventer la commande.\n\n## Découpage minimal — LS8 « filet humain : armement par heure saisie »\n\n**Domaine : RIEN à ajouter.** `plan_resume`, `SessionLimit`, `RateLimitSource::Human`, `ResumePlan::Scheduled` couvrent déjà tout. Une heure saisie par l'utilisateur est fonctionnellement une `SessionLimit` de source `Human` avec `resets_at_ms = Some(epoch)`. Le clamp anti-passé (`max(now)`) protège déjà une saisie déjà échue ⇒ reprise immédiate. C'est le payoff de l'hexagonal : zéro nouveau port, zéro nouvel adapter.\n\n**Application — `SessionLimitService` : une méthode publique.** N'élargis PAS `on_rate_limited` (sémantique « signal de détection auto »). Ajoute une entrée dédiée, en réutilisant strictement la branche `Scheduled` existante :\n\n```rust\n/// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset\n/// pour un agent en limite SUSPECTÉE (AgentRateLimitSuspected, sans heure fiable).\n/// Construit une SessionLimit source `Human`, calcule le plan et arme la reprise\n/// EXACTEMENT comme la branche auto : mêmes événements, même dédoublonnage,\n/// même annulabilité via cancel_resume.\npub fn confirm_human_resume(\n &self,\n agent_id: AgentId,\n node_id: NodeId,\n conversation_id: Option,\n resets_at_ms: i64, // i64 nu, pas Option : la saisie EST l'heure\n)\n```\n\nCorps = copie de la branche `ResumePlan::Scheduled` de `on_rate_limited` (publish `AgentRateLimited{Some}` → `disarm` → `scheduler.arm(ResumeAgent)` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled`), avec `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)`. **Factorise** la branche en un `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` privé appelé par les deux chemins, pour ne pas dupliquer le dédoublonnage. `execute_resume` et `cancel_resume` restent **inchangés** : l'armement humain est annulable et s'exécute par les mêmes voies (c'est l'invariant à préserver — un seul mécanisme de reprise).\n\n**app-tauri — une commande.** Miroir exact de `cancel_resume` :\n\n```rust\n#[tauri::command]\npub async fn set_resume_at(\n agent_id: String,\n resets_at_ms: i64,\n state: State<'_, AppState>,\n) -> Result<(), ErrorDto>\n```\n\nCorps : `parse_agent_id` → résoudre `node_id` et `conversation_id` **côté backend** depuis la registry (le front n'a que l'`agent_id` ; `agentRateLimitSuspected` ne porte que ça) :\n- `node_id` via `TerminalSessions::node_for_agent(&id)` (ou la registry unifiée). Si `None` ⇒ `ErrorDto` INVALID/NOT_FOUND (l'agent n'a plus de cellule vivante — la saisie n'a pas de cible).\n- `conversation_id` via la session structurée `session_for_agent(&id).map(|s| s.conversation_id())` (best-effort `None` toléré, comme tout le chemin de reprise dégradé).\nPuis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`.\n\n**Front — un formulaire minimal.** Sur le badge « limité · heure inconnue — reprise à préciser » : un petit input heure (ou datetime) → calcule l'epoch ms → `invoke(\"set_resume_at\", { agentId, resetsAtMs })`. Dès réception de `AgentResumeScheduled` (déjà consommé !), le badge bascule **automatiquement** sur l'état nominal « limité jusqu'à HH:MM » + compte à rebours + bouton « Annuler la reprise » déjà câblés. Aucun nouvel événement, aucune nouvelle vue d'état côté front : la boucle se referme sur l'UI existante.\n\n## Contrat des événements — RÉUTILISE l'existant, n'en crée AUCUN\n\nC'est le point clé de cohérence : `confirm_human_resume` émet la **même** paire que la branche auto — `AgentRateLimited { agent_id, resets_at_ms: Some(t) }` puis **`AgentResumeScheduled { agent_id, fire_at_ms }`**. Le front les consomme déjà. La transition « suspecté → programmé » se fait donc sans code de présentation neuf : `AgentResumeScheduled` est le pivot qui retire l'état « heure inconnue » et réutilise le rendu nominal. Annulation ⇒ `AgentResumeCancelled` (inchangé). Reprise ⇒ `AgentResumed` (inchangé). `AgentRateLimitSuspected` reste l'unique signal « il faut demander », rien d'autre.\n\n## Garde-fous QA pour le binôme\n\n- Saisie dans le passé : pas un cas d'erreur — `plan_resume` clampe à `now` ⇒ reprise quasi-immédiate. Test à ajouter.\n- Re-saisie / second suspect : `confirm_human_resume` doit passer par le **même `disarm`** que l'auto ⇒ un seul armement par agent (§21.10-4). Test de dédoublonnage croisé (humain après auto, et inverse).\n- Agent disparu entre suspicion et saisie : `node_for_agent == None` ⇒ erreur propre, pas d'armement orphelin.\n- Course cancel-pile-au-tir : inchangée, déjà couverte par `cancel_resume`.\n\nPérimètre total : ~1 méthode service (factorisée) + 1 commande + 1 input front + tests. Aucune frontière nouvelle, aucun port, aucun adapter. À cadrer et livrer en LS8 avant que Git n'envisage le merge `feature/* → develop`."} +{"id":"bac5ae43-e769-4bba-8a18-0419d220abec","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781776488687,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Diagnostic architecture demandé par Main. Contexte: IdeA intègre Codex CLI. Le profil Codex actuel utilise McpConfigStrategy::TomlConfigHome { target: \".codex/config.toml\", home_env: \"CODEX_HOME\" }, donc au lancement IdeA écrit {runDir}/.codex/config.toml et pousse CODEX_HOME vers ce dossier pour isoler MCP/permissions par agent. Problème produit: chaque agent Codex redemande une connexion ChatGPT/OpenAI, et si l’utilisateur crée beaucoup d’agents il atteint une limite de requests/login. De plus un agent jamais lancé peut échouer lors d’une délégation car il n’est pas authentifié. Questions: 1) quelle solution respecte l’architecture hexagonale/SOLID pour partager l’auth Codex au niveau ordinateur/utilisateur tout en gardant la config MCP/permissions isolée par agent ? 2) faut-il introduire un port Auth/RuntimeHealth/Readiness pour préflight au moment de création/configuration d’agent ? 3) quels changements concrets recommandes-tu dans le domaine/application/infrastructure ? Merci de répondre en français, concis mais actionnable."} +{"id":"9c93f287-3aa5-486d-b873-82d811224e54","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781853578633,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Cadrage architecture obligatoire avant implémentation.\n\nContexte produit : nous voulons ajouter à IdeA une gestion automatique de la croissance des conversations d'agents afin de réduire les coûts tokens. Le système existant a déjà : log canonique `.ideai/conversations/.../log.jsonl`, handoff incrémental `handoff.md`, injection du handoff au lancement/relaunch, conversation_id logique de paire IdeA, provider session store, reprise session provider quand possible.\n\nBesoin : mettre en place une compression/rotation automatique des conversations d'agents, mais avec stabilité maximale.\n\nContraintes fortes validées avec l'utilisateur :\n- Ne jamais couper un agent au milieu du développement d'une feature.\n- Ne jamais interrompre un tour en cours, outil en cours, terminal occupé, délégation pendante, ou attente de `idea_reply`.\n- Ne pas casser les contacts inter-agents : les messages doivent cibler l'identité logique AgentId/ConversationId, pas une session physique instable.\n- Si un autre agent essaie de contacter un agent en rotation, le routage doit être stable : queue, attente ou ancienne session jusqu'à bascule sûre.\n- La rotation doit être opportuniste, non interruptive : au seuil normal, préparer/planifier au prochain point sûr ; seuil critique = signaler/attendre point sûr, pas tuer au milieu.\n- Attention au detach/attach de l'agent sur la cellule : la cellule est une vue, pas la session.\n- Ne jamais faire de rotation automatique si l'utilisateur est en train d'écrire dans la cellule : focus, buffer d'entrée non envoyé, frappe récente, IME/composition, prompt interactif si détectable.\n- Nouvelle session idéalement démarrée détachée/background, vérifiée prête, puis bascule atomique du routage + attachement cellule.\n- Checkpoint durable obligatoire avant bascule : log canonique append OK + handoff à jour.\n\nDemande :\n1. Lire l'architecture et le code existant autour agent lifecycle, terminal sessions, orchestrator routing, conversation log/handoff, provider sessions, layout/cell attach.\n2. Proposer un design hexagonal/SOLID précis : domaine, ports éventuels, use cases, adapters, events, UI state.\n3. Découper en lots implémentables DevBackend/DevFrontend/QA, avec tests attendus.\n4. Identifier les pièces déjà existantes à réutiliser et les risques.\n5. Ne pas coder. Rends un rapport utilisable directement par DevBackend/DevFrontend/QA."} +{"id":"1ec96a0b-a48e-4eb5-b58b-935fa07f8bfb","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781870459664,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Contexte bug IdeA: une délégation inter-agent longue envoyée à un agent Codex TUI arrive visiblement tronquée/partielle dans le terminal cible. Code actuel: frontend `frontend/src/features/terminals/useWritePortal.ts` écrit `head.text` en un seul `handle.write(encoder.encode(head.text))`, attend `submitDelayMs` puis écrit `\\r`; chemin headless `crates/infrastructure/src/input/mod.rs::make_headless_sink` écrit aussi `text.as_bytes()` d'un coup puis submit. `makeTerminalHandle` sérialise déjà les invokes et `PortablePtyAdapter::write` fait `write_all+flush`. Hypothèse: le TUI/paste handling ou IPC/PTY avec gros bloc peut perdre/ignorer une fin avant Enter. Proposition de correction locale: introduire un envoi chunké pour les injections de délégation (front et headless), avec chunks UTF-8 safe, pause courte entre chunks, puis délai anti-paste existant avant submit; ack seulement après tous les chunks + submit. Ajouter tests `useWritePortal` qui vérifient texte long chunké en ordre et ack après submit; tests infra input headless si simple. Est-ce compatible avec l'architecture actuelle (single writer, submit config, hexagonal), et vois-tu un meilleur point de correction ? Réponds court avec risques/contraintes."} +{"id":"5875274c-c8d5-4b8e-84a4-e38f5e90cbc7","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781886483226,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Validation rapide architecture pour correctif IdeA: le runtime structuré Codex lance `codex exec` depuis le run dir isolé et doit permettre l’écriture dans le project root quand les permissions résolues donnent workspace-write. Proposition: faire transiter `PreparedContext.project_root`/équivalent depuis `StructuredSessionFactory::start` vers `CodexExecSession`, puis ajouter `--add-dir ` à la ligne `codex exec` en workspace-write, sans ajouter `--ask-for-approval` car `codex exec` le refuse. Est-ce conforme Ports & Adapters/hexagonal ? Signale toute frontière à ne pas franchir."} +{"id":"cd8ddcb8-5c8a-4b2c-b52b-ee09ae878130","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781886498651,"source":{"kind":"agent","agentId":"dce19c75-9669-4e45-b8de-9950025157da"},"role":"response","text":"Oui, c’est conforme à l’architecture Ports & Adapters, avec une réserve de frontière importante.\n\nValidation :\n- Faire porter `project_root` dans un DTO/VO de contexte préparé (`PreparedContext.project_root` ou équivalent) est acceptable si cette donnée représente le périmètre métier d’exécution du projet, pas un détail Tauri/Codex.\n- `StructuredSessionFactory::start` peut transmettre cette donnée vers l’adapter runtime concret : c’est précisément le rôle de la couche application/composition d’orchestrer les ports avec le contexte résolu.\n- Ajouter `--add-dir ` dans `CodexExecSession` est au bon endroit si `CodexExecSession` est l’adapter infrastructure/presentation-backend spécifique à Codex CLI. Le flag `--add-dir` est un détail technique de l’adapter Codex, donc il ne doit pas remonter dans le domaine.\n- Conditionner cet ajout à `workspace-write` est cohérent : l’intention métier est “la session peut écrire dans le workspace résolu”, l’implémentation Codex est “ajouter le root autorisé à la sandbox CLI”.\n- Ne pas ajouter `--ask-for-approval` est correct si `codex exec` le refuse. Il ne faut pas forcer une option non supportée au niveau adapter.\n\nFrontières à ne pas franchir :\n- Ne pas faire dépendre le domaine/application de `codex exec`, `--add-dir`, `--sandbox`, ni des noms de flags Codex. Ces détails restent dans l’adapter Codex.\n- Ne pas laisser `CodexExecSession` recalculer seul le project root à partir du run dir isolé si le project root a déjà été résolu par les couches supérieures. L’adapter doit consommer un contexte préparé, pas réinterpréter l’état global.\n- Ne pas élargir systématiquement les permissions : `--add-dir` doit être ajouté uniquement pour le `projectRoot` validé/résolu du projet et uniquement quand le mode effectif est `workspace-write`.\n- Ne pas confondre `cwd` et périmètre d’écriture : le cwd peut rester le run dir isolé pour l’exécution/session, tandis que `project_root` devient une racine autorisée explicite.\n- Ne pas exposer ce comportement côté React/Tauri commands comme une option utilisateur brute. L’UI demande un lancement d’agent/session ; la politique de sandbox reste dans les services/adapters backend.\n\nFormulation recommandée côté modèle : plutôt qu’un champ trop Codex-spécifique, utiliser un nom neutre comme `workspace_roots`, `writable_roots`, `project_root`, ou `ExecutionScope { cwd, project_root, sandbox_mode }`. Puis l’adapter Codex traduit `ExecutionScope + workspace-write` en `codex exec --add-dir `."} +{"id":"be96b537-e241-4277-82d8-fe26cd851e58","conversation":"7a2f446c-2efa-0c56-08dd-07cdc5cac19b","atMs":1781937959244,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Contexte: l'utilisateur veut terminer les chantiers ouverts d'IdeA, en autonomie, via le cycle Main -> Architect -> Git -> Dev -> QA -> Git. Main ne code pas.\n\nDecision Git deja obtenue:\n- Branche courante: `feature/orchestrator-designation`.\n- Worktree dirty important (~75 fichiers) mais analyse Git: mono-theme = chantier orchestrateur/designation + nouveau sink diagnostic `crates/application/src/diag.rs` pour le rendez-vous inter-agents.\n- Ne pas stash/switch. Premier chantier logique: finir `orchestrator-designation` sur cette branche, puis QA, puis Git commit/merge develop.\n\nEtat memoire projet pertinent:\n- Chantiers restants: agent=session vivante coherente, persistance conversationnelle, handoff cross-profile, live-state partage, refresh memoire/contexte a chaud, UX delegations/conversations, documentation architecture.\n- Le chantier actuel semble etre une brique d'orchestration/designation, avec modifications dans domain/application/infrastructure/app-tauri/frontend terminal write portal et permissions/sandbox/session structured.\n\nTa mission Architect:\n1. Cadre le chantier courant `orchestrator-designation` a partir de son nom et de l'etat Git ci-dessus: objectif architectural probable, frontieres de couches, contrats/ports/DTO a verifier, invariants attendus.\n2. Donne a Main un plan de delegation concret: ce que DevBackend doit verifier/finaliser, ce que DevFrontend doit verifier/finaliser, ce que QA doit tester.\n3. Donne des criteres de fin stricts pour considerer ce chantier ferme.\n4. Indique s'il faut mettre a jour `architect.md` maintenant ou attendre la fin de la stabilisation.\n\nNe code pas. Reponds avec `idea_reply` si cette tache est deleguee via IdeA."} diff --git a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md index 1aa746c..dac8b45 100644 --- a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md +++ b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/handoff.md @@ -1,14 +1,9 @@ --- -upTo: 21ed91d7-af2d-4537-9952-8236c7faafe4 +upTo: edf1ef36-9b9f-41f8-9fde-dfddf09366f9 objective: IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation --- **Objectif :** IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation -- **Prompt:** Lot LP4-4 — implémenter l'enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON), aujourd'hui non sandboxé. L'Architecte a validé l'approche (GO). Implémente exactement ce découpage ; ne réinvente pas la stratégie. STRATÉGIE VALIDÉE (approche b) : transposer la technique du PTY (`spawn_command_sandboxed` dans `crates/infrastructure/src/pty/mod.rs`). Le `pre_exec(enforce)` est INTERDIT (landlock alloue → deadlock malloc post-fork en process multithreadé). À la place : `enforce(plan)` sur un THREAD JETABLE AVANT le fork, puis spawn `std::process` synchrone depuis ce thread (l'enfant hérite le domaine Landlock via fork+exec), réconcilié à l'async par `spawn_blocking`. Le chemin non-sandboxé (sandbox==None OU pas d'enforcer, et tout non-Linux) reste le `drain` async tokio ACTUEL strictement inchangé (zéro régression). CONTRAT À MODIFIER : 1. `crates/domain/src/ports.rs` (~537) — `AgentSessionFactory::start` : ajouter param `sandbox: Option<&SandboxPlan>` (SandboxPlan est domaine, franchit déjà le port via SpawnSpec.sandbox — cohérent). 2. `crates/infrastructure/src/session/process.rs` — ajouter `pub sandbox: Option` à `SpawnLine` ; `run_turn` reçoit `enforcer: Option<&Arc>` ; nouveau `drain_sandboxed` : thread jetable → `enforcer.enforce(&plan)` (fail-closed : Err ⇒ échec du tour, AUCUN child ne tourne) → `std::process::Command::spawn` → poser un `unsafe { cmd.pre_exec(|| Ok(())) }` VIDE (async-signal-safe) pour forcer le chemin fork+exec déterministe → drain bloquant stdin/stdout→EOF→wait → Vec. Le thread meurt avec sa restriction. TIMEOUT sous sandbox : le thread renvoie son killer (Arc> ou pid) via un oneshot juste après spawn ; `tokio::time::timeout` sur le JoinHandle ; à expiration kill le child → EOF → le thread finit → renvoyer Timeout (pas de zombie/thread bloqué). 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` gagne `Option>` + builder `with_sandbox_enforcer(...)` (jumeau exact de PortablePtyAdapter::with_sandbox_enforcer) ; `start` apparie plan (par-appel) + enforcer (par-instance) et les injecte dans `ClaudeSdkSession::new`/`CodexExecSession::new`. 4. `crates/infrastructure/src/session/claude.rs` (build_spawn_line ~187, send ~195) & `codex.rs` (~162/195) — stocker plan+enforcer, remplir `SpawnLine.sandbox`, passer l'enforcer à `run_turn`. 5. `crates/application/src/agent/lifecycle.rs` — `launch_structured` (~1620) reçoit le plan (`spec.sandbox`, déjà calculé en step 5d) et le relaie à `factory.start`. 6. `crates/app-tauri/src/state.rs` (~408) — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. 7. FAKES à mettre à jour (nouvelle signature start) : `crates/domain/tests/structured_session_d0.rs`, `crates/application/tests/structured_launch_d3.rs`, `crates/application/tests/orchestrator_service.rs`. INVARIANT PRODUIT : eff==None ⇒ plan None ⇒ SpawnLine.sandbox None ⇒ drain async tokio inchangé, comportement natif. Respecte l'hexagonal (la résolution policy→plan reste pure dans le domaine ; l'infra ne fait qu'orchestrer + enforcer). À la fin : `cargo build` + `cargo test -p domain -p infrastructure -p application -p app-tauri` verts. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichiers modifiés, comment le timeout sous sandbox est géré, et la sortie RÉELLE des tests. Si quelque chose bloque ou ne passe pas, rapport clair avec sortie brute — je renverrai à l'Architecte ou on bouclera. Les tests d'enforcement e2e du chemin structuré seront écrits ensuite par QA (les 7 invariants), tu n'as pas à les écrire, mais assure-toi que les suites existantes restent vertes. -- **Response:** LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ : FAIT, build + tests verts (80 suites, 0 failed). NON committé (figé par l'orchestrateur). ⚠️ UNE DIVERGENCE ASSUMÉE vs le cadrage (le `pre_exec` vide), détaillée plus bas — à valider par l'Architecte. Tout le reste est conforme au découpage. ## Fichiers modifiés (12) Production (6) : 1. `crates/domain/src/ports.rs` — `AgentSessionFactory::start` gagne `sandbox: Option<&crate::sandbox::SandboxPlan>` (dernier param). 2. `crates/infrastructure/src/session/process.rs` — `SpawnLine.sandbox: Option` ; `run_turn(spec, timeout, enforcer: Option<&Arc>)` ; nouveau `run_turn_sandboxed` (thread jetable + 2 oneshot killer/résultat + `tokio::time::timeout`) et `drain_sandboxed` (drain bloquant std), tous deux `#[cfg(target_os = "linux")]`. 3. `crates/infrastructure/src/session/factory.rs` — `StructuredSessionFactory` porte `Option>` + builder `with_sandbox_enforcer(...)` (jumeau de `PortablePtyAdapter`) ; `start` apparie plan (param, par-appel) + enforcer (champ, par-instance) → `ClaudeSdkSession::new`/`CodexExecSession::new`. (N'est plus `Copy`/`Debug` car porte un `Arc` ; `new()` n'est plus `const`.) 4. `crates/infrastructure/src/session/claude.rs` — stocke `sandbox`+`sandbox_enforcer`, remplit `SpawnLine.sandbox`, passe l'enforcer à `run_turn`. 5. `crates/infrastructure/src/session/codex.rs` — idem Claude. 6. `crates/application/src/agent/lifecycle.rs` — `launch_structured` reçoit `sandbox: Option<&SandboxPlan>` et le relaie à `factory.start` ; le call site passe `spec.sandbox.as_ref()` (déjà calculé en step 5d de LP4-3). 7. `crates/app-tauri/src/state.rs` — `StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer())`. Fakes/tests adaptés à la nouvelle signature (5) : `domain/tests/structured_session_d0.rs`, `application/tests/structured_launch_d3.rs`, `application/tests/orchestrator_service.rs` (les 3 fakes `start` + leurs call sites), plus `infrastructure/src/session/{mod.rs,conformance.rs}` (SpawnLine literals + tous les `run_turn`/`ClaudeSdkSession::new`/`CodexExecSession::new` internes). ## Gestion du timeout sous sandbox Le thread jetable n'est pas annulable de l'extérieur. Deux canaux `tokio::sync::oneshot` le réconcilient avec l'async : - **killer** : juste après le spawn, le thread envoie un `Arc>` ; il a au préalable **sorti `stdout`** du child (`.take()`) pour drainer SANS tenir le lock — donc le killer peut verrouiller à tout instant. - **résultat** : le thread envoie `Result, _>` à la fin. `tokio::time::timeout(dur, done_rx)` : - fini dans les temps ⇒ on rend le résultat ; - **expiration** ⇒ on `killer_rx.await` puis `child.lock().kill()` → l'enfant meurt → `stdout` EOF → la boucle de drain du thread sort → `child.wait()` (reap, **pas de zombie**) → le thread se termine. On rend `AgentSessionError::Timeout`. Le `JoinHandle` est détaché (pas de thread bloqué). `done_tx` lâché sans valeur (panique) ⇒ `Io`. `enforce` est **fail-closed** : `Err` ⇒ `AgentSessionError::Start` AVANT tout spawn (aucun enfant ne tourne). ## ⚠️ Divergence : pas de `pre_exec` vide (à arbitrer Architecte) Le cadrage demandait `unsafe { cmd.pre_exec(|| Ok(())) }` pour forcer std sur le chemin déterministe `fork`+`exec`. **Impossible ici** : la crate `infrastructure` est `#![forbid(unsafe_code)]` (lib.rs:12) — un `forbid` ne peut PAS être relâché localement par `#[allow]`. Le PTY n'avait jamais eu besoin d'`unsafe` : il obtient `fork`+`exec` gratuitement via le `pre_exec` **interne de portable-pty**. Sur le chemin structuré on pilote `std::process` en direct, donc le `pre_exec` serait à nous → `unsafe` → interdit. J'ai donc **retiré le `pre_exec`** sans perte de garantie de sécurité : `landlock_restrict_self` restreint le **thread courant et toute sa descendance**, héritage assuré par le noyau à travers `fork`/`clone`/`vfork` ET préservé par `execve` — **y compris via `posix_spawn`** (= `clone`+`execve` sous le capot), car l'enforcement vit au niveau des *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le `fork`+`exec` n'était une garantie que de **déterminisme**, pas de sécurité ; l'enforce()-avant-spawn-sur-le-même-thread suffit. La technique validée (thread jetable, enforce avant spawn, fail-closed, reap) est intacte. Deux options pour l'Architecte s'il veut le `fork`+`exec` strict du cadrage : (a) rester comme livré (zéro unsafe, héritage noyau — mon choix par défaut) ; (b) passer lib.rs en `#![deny(unsafe_code)]` + `#[allow(unsafe_code)]` scoppé sur `drain_sandboxed` pour rétablir le `pre_exec`. J'attends son verdict avant de toucher l'invariant crate-wide. ## Invariant produit conservé `eff==None` ⇒ plan `None` ⇒ `SpawnLine.sandbox==None` ⇒ `run_turn` emprunte le **drain async tokio inchangé** (et **tout** non-Linux y reste, via `#[cfg(target_os="linux")]` sur le routage). Zéro régression. Hexagonal respecté : résolution policy→plan pure (domaine, step 5d), l'infra ne fait qu'orchestrer + enforcer ; le plan franchit le port en valeur domaine. ## Sortie réelle des tests `cargo build` : Finished, OK. `cargo test -p domain -p infrastructure -p application -p app-tauri` : **80 suites, toutes `ok`, 0 failed, 0 panic**. Notables : - domain (lib) : 141 passed ; `structured_session_d0` (fake start nouvelle signature) vert. - infrastructure (lib) : 156 passed (incluait 154 avant LP4-4 ; les nouveaux chemins compilent et tous les tests session/process restent verts) ; suites sandbox/permission toutes 0 failed. - application (lib) : 72 passed ; `structured_launch_d3`, `orchestrator_service` verts. - app-tauri : 47 passed. Un seul warning, **pré-existant et hors périmètre** : `method writes is never used` (orchestrator_service.rs:1641, helper de fixture mort avant LP4-4). Prêt pour QA (7 invariants e2e d'enforcement du chemin structuré : agent structuré lancé avec Deny ⇒ écriture impossible, etc.). -- **Prompt:** LS1 — couche DOMAINE de la feature « Gestion des limites de session des agents ». Branche courante : feature/agent-session-limits (commit cadrage fa5b826). Code PUR uniquement, aucune I/O, aucun ajout de dépendance (surtout PAS `regex` dans le crate domain). Respecte strictement le cadrage Architect (ARCHITECTURE.md §21) et les 5 corrections hexagonales. À implémenter dans crates/domain : 1. `ports.rs` — étendre l'enum `ReplyEvent` avec une variante `RateLimited { resets_at_ms: Option }`. CONTRAINTE CLÉ (T4) : `RateLimited` est NON TERMINAL — il s'intercale comme `Heartbeat`, seul `Final` clôt le flux. Documente-le dans le doccomment de la variante, en cohérence avec le contrat `ReplyStream` existant. 2. `readiness.rs` — étendre `ReadinessSignal` avec une variante `RateLimited { resets_at_ms: Option }` et mettre à jour `ReadinessPolicy::classify` pour mapper `ReplyEvent::RateLimited` → `Some(ReadinessSignal::RateLimited{..})`. Garde la sémantique existante (Final→TurnEnded ; TextDelta/ToolActivity/Heartbeat→None). `RateLimited` n'est PAS terminal (il ne marque pas le tour comme fini au sens TurnEnded). 3. `session_limit.rs` (NOUVEAU module, déclaré dans lib.rs) : - VO `SessionLimit` portant au minimum `resets_at_ms: Option` (+ éventuellement l'instant de détection si utile, en i64 époche-ms). Type pur, Clone/Debug/PartialEq/Eq. - fonction pure `plan_resume(...)` qui, à partir d'un `SessionLimit` (+ données nécessaires comme conversation_id), calcule le plan de reprise (deadline de réveil = resets_at_ms, et l'info nécessaire pour relancer). Reste 100% pur/testable, pas de Scheduler ici (le port Scheduler c'est LS3). 4. `profile.rs` — ajouter un champ optionnel `rate_limit_pattern` (donnée déclarative pour le niveau 2 : le PATTERN brut + ce qu'il faut pour extraire l'heure, mais SANS moteur regex — le domaine stocke seulement la donnée, le moteur vivra en infra au LS5). Calque-toi sur la façon dont `prompt_ready_pattern` est porté. Type dédié `RateLimitPattern` si pertinent. Veille à la sérialisation serde et à la rétro-compat (champ optionnel/`#[serde(default)]`). Écris le code propre, documenté (doccomments fr cohérents avec l'existant), faiblement couplé. NE touche PAS encore aux adapters infra ni à l'application (lots ultérieurs). Quand c'est prêt : ne lance pas les tests toi-même au-delà d'un `cargo check`/`cargo build` du crate domain pour valider la compilation — l'écriture et l'exécution des tests unitaires reviennent à QA (binôme). Rends-moi : la liste des fichiers modifiés/créés, les signatures publiques ajoutées, et le résultat du `cargo build -p domain`. -- **Response:** LS1 (couche DOMAINE) terminé. Code pur, zéro I/O, zéro nouvelle dépendance (pas de `regex` dans domain). `cargo build -p domain` = **OK, 0 warning** (Finished dev profile, 1.88s). ## Fichiers **Modifiés** - `crates/domain/src/ports.rs` — variante `ReplyEvent::RateLimited { resets_at_ms: Option }` ; doccomment explicite « jamais terminal » (T4) + note d'intégration « clos sans Final = fin gracieuse limitée, pas une erreur ». - `crates/domain/src/readiness.rs` — variante `ReadinessSignal::RateLimited { resets_at_ms: Option }` (enum reste `Copy`) ; `classify` mappe `ReplyEvent::RateLimited` → `Some(ReadinessSignal::RateLimited{..})` (seul arm ajouté ; Final→TurnEnded et TextDelta/ToolActivity/Heartbeat→None inchangés). - `crates/domain/src/profile.rs` — type `RateLimitPattern` (donnée pure, pas de moteur regex) + champ `AgentProfile.rate_limit_pattern: Option` (`#[serde(default, skip_serializing_if = "Option::is_none")]`, défaut `None` dans `new`) + builder `with_rate_limit_pattern`. - `crates/domain/src/lib.rs` — `pub mod session_limit;` + re-exports (`RateLimitPattern` ; `plan_resume, ResumePlan, RateLimitSource, SessionLimit`). **Créé** - `crates/domain/src/session_limit.rs` — VO `SessionLimit` + `RateLimitSource` + `ResumePlan` + fn pure `plan_resume`. ## Signatures publiques ajoutées **ports.rs** - `ReplyEvent::RateLimited { resets_at_ms: Option }` **readiness.rs** - `ReadinessSignal::RateLimited { resets_at_ms: Option }` - `ReadinessPolicy::classify(&ReplyEvent) -> Option` (comportement étendu, signature inchangée) **session_limit.rs** - `enum RateLimitSource { Structured, Pattern, Human }` (Copy) - `struct SessionLimit { resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource }` (Copy/Debug/PartialEq/Eq) - `const fn SessionLimit::new(Option, i64, RateLimitSource) -> Self` - `const fn SessionLimit::has_known_reset(&self) -> bool` - `enum ResumePlan { Scheduled { fire_at_ms: i64, conversation_id: Option }, HumanFallback }` - `fn plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option) -> ResumePlan` **profile.rs** - `struct RateLimitPattern { pattern: String, reset_capture: Option, time_format: Option }` (serde camelCase ; champs optionnels omis si `None`) - `fn RateLimitPattern::new(impl Into, Option, Option) -> Result` (rejette pattern vide → `EmptyField`) - `AgentProfile.rate_limit_pattern: Option` - `fn AgentProfile::with_rate_limit_pattern(self, RateLimitPattern) -> Self` ## Décisions de conception (à valider) 1. **`plan_resume` renvoie un `enum ResumePlan`** plutôt que `Option`/`{fire_at_ms}` nu : la variante `HumanFallback` matérialise « pas de plan auto » (resets_at_ms `None` → filet humain §21.1-3) tout en respectant la signature `-> ResumePlan` du cadrage, et `Scheduled` porte le `conversation_id` que tu m'as demandé d'inclure (« l'info nécessaire pour relancer »). `ScheduledTask`/`AgentId`/`NodeId` du §21.4 restent côté application (LS3/LS4), je ne les ai pas tirés dans le domaine. 2. **Clamp anti-passé** : `fire_at_ms = max(resets_at_ms, now_ms)` — on ne programme jamais une échéance dans le passé (reset déjà écoulé → reprise immédiate). Documenté ; testable purement. 3. `RateLimitPattern.reset_capture` et `time_format` sont **optionnels** : un motif peut détecter la limite sans extraire d'heure → `RateLimited{None}` → filet humain. Opaques au domaine (le moteur regex + parsing d'heure = infra LS5). ## Point d'attention pour la suite (PAS traité, hors périmètre du ticket) - **`events.rs`** (5 variantes `AgentRateLimited`/`AgentResumeScheduled`/`AgentResumeCancelled`/`AgentResumed`/`AgentRateLimitSuspected`) fait partie de LS1 au §21.9 mais **n'était pas** dans les 4 items de mon ticket — je ne l'ai donc pas implémenté. À me reconfier si tu veux compléter LS1. - **Impact downstream attendu** : `ReplyEvent` gagne une variante ⇒ les `match` exhaustifs en `infrastructure` (session/claude.rs, codex.rs…) **ne compileront plus** tant que LS2 n'a pas ajouté l'arm. Normal et attendu (j'ai bien laissé infra/application intacts) ; `cargo build --workspace` échouera jusqu'à LS2. QA (binôme) peut écrire les tests LS1 : `classify` mappe RateLimited ; `plan_resume` avec/sans `resets_at` + clamp passé ; round-trip serde du profil (clé `rateLimitPattern` omise si `None`, legacy JSON → `None`) ; `RateLimitPattern::new` rejette vide ; `ReadinessSignal` reste `Copy`. -- **Prompt:** Complément LS1 (domaine) — il reste les variantes d'événements du §21.9 que ton ticket précédent ne couvrait pas. Toujours sur feature/agent-session-limits, code pur, pas de dépendance nouvelle. Dans crates/domain/src/events.rs : ajoute au type d'événement de domaine (DomainEvent) les 5 variantes prévues au cadrage §21.9 : - `AgentRateLimited` (un agent vient d'entrer en limite ; porte au minimum agent_id et resets_at_ms: Option) - `AgentResumeScheduled` (réveil de reprise armé ; agent_id + fire_at_ms: i64) - `AgentResumeCancelled` (l'utilisateur a annulé la reprise auto ; agent_id) - `AgentResumed` (l'agent a effectivement été relancé ; agent_id) - `AgentRateLimitSuspected` (filet humain niveau 3 : limite suspectée, confirmation à demander ; agent_id + resets_at_ms: Option si dispo) Aligne-toi STRICTEMENT sur la forme des variantes existantes de DomainEvent (mêmes conventions de nommage de champs, types d'ids déjà utilisés comme AgentId, doccomments fr cohérents). Respecte le §21 d'ARCHITECTURE.md pour les champs exacts si le cadrage les précise. Mets à jour les re-exports lib.rs si nécessaire. Ne touche à rien d'autre (infra/application intacts). Rends : fichiers modifiés, signatures des 5 variantes, et résultat de `cargo build -p domain`. - **Response:** Complément LS1 terminé. Code pur, zéro dépendance nouvelle. `cargo build -p domain` = **OK, 0 warning** (Finished, 0.77s). ## Fichier modifié - `crates/domain/src/events.rs` — 5 variantes ajoutées à `DomainEvent`, insérées juste avant `PtyOutput`. `lib.rs` **inchangé** : `DomainEvent` est déjà re-exporté (`pub use events::{DomainEvent, OrchestrationSource};`) et les variantes d'enum n'ont pas de re-export propre. Rien d'autre touché (infra/application intacts). ## Signatures des 5 variantes ```rust AgentRateLimited { agent_id: AgentId, resets_at_ms: Option } AgentResumeScheduled { agent_id: AgentId, fire_at_ms: i64 } AgentResumeCancelled { agent_id: AgentId } AgentResumed { agent_id: AgentId } AgentRateLimitSuspected { agent_id: AgentId, resets_at_ms: Option } ``` Choix de champs alignés sur le cadrage §21.3/§21.9 et sur les conventions existantes de `DomainEvent` : - `agent_id: AgentId` (type d'id déjà utilisé partout dans l'enum) ; - `resets_at_ms: Option` (époche-ms, homogène avec `Clock`/`SessionLimit`/`ReplyEvent::RateLimited` ; T1) ; - `fire_at_ms: i64` (échéance du réveil, même type/sémantique que `ResumePlan::Scheduled.fire_at_ms`). - Doccomments fr cohérents avec le vocabulaire §21 (limité/reset/reprise auto annulable/filet humain niveau 3). `AgentRateLimitSuspected` porte `resets_at_ms: Option` (le cadrage le donne « si dispo ») : `None` = l'utilisateur fournira l'heure. ## Vérifs - Pas de `match` exhaustif sur `DomainEvent` cassé dans le crate domain (seul `input.rs` le référence, en construction d'événements — build vert). - Rappel inchangé : `cargo build --workspace` échouera toujours tant que LS2 n'a pas câblé l'arm `ReplyEvent::RateLimited` côté infra (et l'éventuel relais IPC des nouveaux events en LS7) — attendu. QA peut compléter les tests LS1 : présence/forme des 5 variantes (constructibilité + égalité `PartialEq`), types de champs. - **Prompt:** LS2 — niveau 1 (structuré) côté INFRA, adapter Claude. Branche feature/agent-session-limits (LS1 mergé : domaine porte déjà ReplyEvent::RateLimited { resets_at_ms: Option }, non terminal). Respecte ARCHITECTURE.md §21 et les corrections T4. Objectif : exploiter le `rate_limit_event` que l'adapter Claude voit DÉJÀ mais jette aujourd'hui (infrastructure/session/claude.rs, fonction pure `parse_event`, ~ligne 90 : actuellement `Some("rate_limit_event") => vec![ReplyEvent::Heartbeat]`). Il faut en extraire l'heure de reset et émettre `ReplyEvent::RateLimited { resets_at_ms }` à la place. À faire : 1. `infrastructure/session/claude.rs` — `parse_event` : - Pour `type == "rate_limit_event"` : lire `rate_limit_info` et en extraire le timestamp de reset → `resets_at_ms: Option` (époche-ms). Émettre `ReplyEvent::RateLimited { resets_at_ms }` au lieu du Heartbeat. Si `rate_limit_info` est absent/illisible/sans champ de reset → `resets_at_ms = None` (on émet quand même RateLimited{None} : limite détectée, heure inconnue → filet humain en aval). Robustesse : jamais d'erreur sur un rate_limit_event malformé. - SPIKE à résoudre proprement : le format réel du champ de reset n'est pas garanti. Isole le parsing du timestamp dans une FONCTION PURE dédiée (ex. `fn parse_reset_ms(rate_limit_info: &Value) -> Option`) qui gère défensivement les formats plausibles et les convertit tous en époche-ms : (a) entier epoch en SECONDES, (b) entier epoch en MILLISECONDES, (c) chaîne ISO-8601/RFC3339. Heuristique secondes-vs-ms documentée (seuil de magnitude). Cherche les noms de champ plausibles (`resetsAt`, `resets_at`, `reset_at`, `retryAfter`/`retry_after` relatif en secondes → now+delta… mais comme parse_event est pur et n'a pas `now`, traite le relatif via une variante distincte si présent, sinon ignore et documente). Documente le format retenu et les hypothèses (comme le fait déjà l'en-tête de claude.rs pour le spike S1). 2. T4 — RateLimited NON TERMINAL : dans la boucle `send` de claude.rs, `RateLimited` ne doit PAS rompre le flux (seul `Final` rompt via `break 'lines`). Il s'intercale comme un Heartbeat. VÉRIFIE aussi le drain de process (infrastructure/session/process.rs, `drain_bounded`/`run_turn`) : un tour qui se clôt SANS `Final` PARCE QUE l'agent est limité ne doit pas devenir `AgentSessionError::Io`/timeout — c'est une fin gracieuse. Si le drain considère aujourd'hui « pas de Final » comme une erreur, adapte pour qu'un flux contenant un `RateLimited` terminal-de-fait soit traité gracieusement. Décris précisément ce que tu changes ici (c'est le point d'intégration sensible signalé par Architect). 3. Corrige les `match` exhaustifs sur `ReplyEvent` désormais cassés en infra : `session/codex.rs` et tout autre consommateur. Pour Codex, si tu n'as pas de signal de limite fiable sous la main, ajoute simplement l'arm `ReplyEvent::RateLimited { .. }` traité comme non terminal/équivalent Heartbeat (le signal Codex natif est un spike ultérieur) — ne fabrique rien de faux. Ne touche pas à l'application/front (lots suivants). NE traite pas le niveau 2 regex (LS5). Garde le parsing isolé et pur pour que QA le teste sans process. Lance `cargo build -p infrastructure` (et `cargo build --workspace` pour voir où en sont les match restants — note ce qui casse encore et relève d'un lot ultérieur). Rends : fichiers modifiés, la signature + les hypothèses de `parse_reset_ms`, ce que tu as changé pour T4 dans process.rs, et le résultat des builds. - **Response:** LS2 (niveau 1 structuré, adapter Claude) terminé. `cargo build -p infrastructure` = **OK, 0 warning**. `cargo build -p domain` = OK. `cargo build --workspace` = **2 erreurs résiduelles, uniquement dans app-tauri** (attendu, relève de LS7/LS8 — détail plus bas). ## Fichiers modifiés - `crates/infrastructure/src/session/claude.rs` — seul fichier touché : - `parse_event` : `rate_limit_event` émet désormais `ReplyEvent::RateLimited { resets_at_ms }` (au lieu de jeter l'info en `Heartbeat`). `resets_at_ms = value.get("rate_limit_info").and_then(parse_reset_ms)` ⇒ absence/illisibilité ⇒ `RateLimited{None}` (jamais d'erreur). - **Nouvelle fonction pure `parse_reset_ms`** + helpers privés purs (`value_to_epoch_ms`, `int_epoch_to_ms`, `float_epoch_to_ms`, `parse_rfc3339_to_ms`, `split_tz`, `split_seconds_frac`, `days_from_civil`). - Doccomments (en-tête `parse_event` + commentaire boucle `send`) mis à jour pour le mapping et la non-terminalité T4. **Aucune dépendance ajoutée** (pas de `chrono`/`time` : parser ISO écrit à la main, pur — `regex` reste réservé à LS5 par T2). ## `parse_reset_ms` — signature & hypothèses ```rust pub fn parse_reset_ms(rate_limit_info: &Value) -> Option // -> époche-ms ``` - **Noms de champ** essayés dans l'ordre, 1er présent gagne : `resetsAt`, `resets_at`, `reset_at`, `resetAt`, `reset`. - **Conversion par type** (`value_to_epoch_ms`) : entier/float epoch **secondes** (magnitude `<10^12`) ⇒ ×1000 ; epoch **ms** (`≥10^12`) ⇒ tel quel ; **chaîne** ⇒ tentée entier/float (même heuristique) puis **ISO-8601/RFC3339**. - **Heuristique s-vs-ms** : seuil `EPOCH_MS_THRESHOLD = 10^12` (10^12 ms ≈ 2001-09 ; 10^12 s ≈ an 33658 ⇒ toute date ≥1970 tombe du bon côté). Documenté en clair. - **ISO/RFC3339** (`parse_rfc3339_to_ms`) : `YYYY-MM-DDThh:mm:ss[.fff][Z|±hh:mm]`, fraction tronquée/complétée à 3 chiffres (ms), conversion jour-civil via algo de Howard Hinnant (`days_from_civil`, exact, bissextiles/siècles). - **Hypothèses assumées** (documentées dans le doccomment) : 1. **`retryAfter`/`retry_after` (relatif, secondes) NON exploité** : le résoudre exige `now`, or `parse_event`/`parse_reset_ms` sont **purs sans horloge** ⇒ ignoré ⇒ `RateLimited{None}` (filet humain). Une résolution `now+delta` pourra être faite côté application (LS4, qui détient `Clock`). 2. **Chaîne ISO sans fuseau ⇒ traitée UTC** (best-effort ; l'heure murale locale relève du niveau 2/LS5, §21.10-2). 3. Jamais de panique ni d'erreur : tout cas inexploitable ⇒ `None`. - **Spike §21.10-1** : le format réel du champ reste à confirmer sur un vrai `rate_limit_event` ; le parser couvre défensivement les 3 formats plausibles et le domaine ne voit que des époche-ms quoi qu'il arrive. ## T4 — non-terminalité (point d'intégration sensible) - **claude.rs `send`** : la boucle ne rompt (`break 'lines`) **que** sur `Final` (`matches!(event, ReplyEvent::Final{..})`). `RateLimited` (comme `Heartbeat`) **ne rompt pas** : il s'intercale, le flux continue jusqu'au `Final` ou la clôture. Comportement déjà correct par construction — j'ai seulement corrigé le commentaire (qui décrivait `rate_limit_event` comme un « heartbeat de fin »). - **process.rs : AUCUN changement nécessaire, et c'est volontaire.** `run_turn`/`drain` drainent **toutes** les lignes jusqu'à EOF puis `wait()` et renvoient `Ok(lignes)` ; ils n'ont **aucune notion de `Final`** et ne transforment **jamais** « pas de Final » en `Io`/`Timeout` (le `Timeout` n'arrive que sur un vrai dépassement de l'horloge murale). Un tour clos sans `Final` parce que limité y est donc **déjà** une fin gracieuse. C'est confirmé par le test existant `stream_without_final_is_silently_ok_at_adapter_level` (`session/mod.rs:794`). - **Où vit réellement « flux clos sans Final ⇒ Io » : dans l'APPLICATION**, pas l'infra — `application/src/agent/structured.rs::drain_bounded_events` (tests `drain_with_readiness_lot1.rs:248`, `send_blocking_d1.rs:161`). La réconciliation T4 (« clos + RateLimited vu ⇒ pas d'Io, issue limitée ») doit donc se faire **là**, ce qui correspond exactement au périmètre **LS4** du cadrage (§21.5). Je n'y ai pas touché (hors périmètre LS2). À flaguer pour LS4. ## Codex (point 3 du ticket) - `session/codex.rs` **n'a PAS de `match` exhaustif sur `ReplyEvent`** : `parse_event` filtre sur le `type` (chaîne) avec un bras `_ => {}`, et la boucle `send` utilise `matches!(…, Final)`. **Rien à corriger, il compile tel quel.** Je n'ai donc **rien ajouté** côté Codex (pas de signal de limite fiable sous la main ⇒ niveau 2/LS5 ou spike ultérieur §21.10-3 ; « ne fabrique rien de faux »). ## Builds restants à corriger en lot ultérieur (ce qui casse encore, hors LS2) `cargo build --workspace` ⇒ **2 erreurs, toutes dans `app-tauri`** (présentation/composition root) : 1. `crates/app-tauri/src/chat.rs:185` (`to_reply_chunk`) — `match` exhaustif sur `ReplyEvent`, `RateLimited` non couvert. ⇒ **LS8** (mapping vers un `ReplyChunk` / DTO front). 2. `crates/app-tauri/src/events.rs:261` — `match` exhaustif sur `DomainEvent`, les **5 nouvelles variantes** (`AgentRateLimited`, `AgentResumeScheduled`, `AgentResumeCancelled`, `AgentResumed`, `AgentRateLimitSuspected`) non couvertes. ⇒ **LS7** (relais IPC camelCase). Aucun autre crate ne casse (domain/application/infrastructure verts). ## Pour QA (binôme LS2) `parse_reset_ms` / `parse_event` sont **purs, testables sans process** : - `rate_limit_event` avec `resetsAt` epoch **secondes** ⇒ `RateLimited{Some(ms ×1000)}` ; - idem epoch **ms** ⇒ tel quel ; **ISO-8601 `…Z`** ⇒ ms attendus ; **ISO avec offset `±hh:mm`** ⇒ ms UTC ; - `rate_limit_event` **sans** `rate_limit_info` / clé inconnue / valeur pourrie ⇒ `RateLimited{None}` (jamais d'erreur) ; - un tour `rate_limit_event` + `result` ⇒ flux `[…, RateLimited, Final]` (RateLimited n'interrompt pas, Final clôt) ; - `days_from_civil`/bissextiles : `1970-01-01T00:00:00Z ⇒ 0`, `2024-02-29T…` valide. @@ -23,4 +18,9 @@ objective: IMPLÉMENTATION (code de production uniquement — PAS les nouveaux t - **Prompt:** LS7 — câblage de la feature session-limits au composition root (app-tauri). Project root: /home/anthony/Documents/Projects/IdeA. Périmètre : UNIQUEMENT app-tauri (state.rs, commands.rs, lib.rs) + au besoin exposer un champ sur LaunchAgentOutput. Aucun nouveau port domaine, aucun nouvel adapter infra : c'est du câblage de l'existant. Architect a fourni la carte de câblage suivante ; suis-la fidèlement, dans l'ORDRE D'IMPLÉMENTATION recommandé. Vérifie chaque ancrage (fichier/struct/ligne) toi-même avant d'éditer car les lignes ont pu bouger. === CARTE DE CÂBLAGE ARCHITECT === 1. INSTANCIATION DU SERVICE — dans crates/app-tauri/src/state.rs, AppState::build. Réutiliser clock (SystemClock, Arc) et event_bus partagé (events_port, Arc). Séquence (après construction de launch_agent et project_store, avant le bloc orchestrateur) : a. let (resume_tx, resume_rx) = tokio::sync::mpsc::unbounded_channel::(); b. let scheduler = Arc::new(TokioScheduler::new(resume_tx, Arc::clone(&clock) as Arc)) as Arc; c. let resumer = Arc::new(AppAgentResumer::new(...)) as Arc; d. let session_limit_service = Arc::new(SessionLimitService::new(Arc::clone(&clock) as Arc, scheduler, Arc::clone(&events_port), resumer)); Ajouter champ `pub session_limit_service: Arc` à AppState et le renvoyer dans le littéral final. resume_rx N'entre PAS dans AppState : il est moved dans la tâche de drain spawné dans build (§5). Imports : application::{SessionLimitService, AgentResumer}, domain::ports::{Scheduler, ScheduledTask}, infrastructure::TokioScheduler. 2. PORT AgentResumer → LaunchAgent — nouvel adapter AppAgentResumer dans state.rs, à côté des passerelles AppHandoffProvider / AppProviderSessionProvider / AppRecordTurnProvider (même patron impl application::Trait for AppXxx). impl application::AgentResumer { async fn resume(agent_id, node_id, conversation_id, resume_prompt) -> Result<(),AppError> } recompose un LaunchAgentInput et appelle self.launch_agent.execute(...) (le MÊME Arc que la commande launch_agent). LaunchAgent applique déjà SessionPlan::Resume quand conversation_id présent. ⚠️ POINT DUR : AgentResumer::resume et ScheduledTask::ResumeAgent ne portent PAS de project_id, mais LaunchAgentInput exige Project complet + rows/cols + mcp_runtime. Solution : AppAgentResumer détient un Arc>> (ResumeContext = { project: Project, rows: u16, cols: u16 }) ALIMENTÉ par la commande launch_agent (là où project/rows/cols sont en main) et lu au resume. mcp_runtime recalculé dans resume via crate::mcp_endpoint::{idea_exe_path, mcp_endpoint} (même recette que la commande launch_agent). store_port injecté en repli. Injection du resume_prompt (constante application::RESUME_PROMPT) comme premier tour : pour le chemin PTY natif, réutiliser le médiateur d'entrée / portail d'écriture PTY (MediatedInbox) plutôt qu'un write brut. 3. TAP NIVEAU 1 (structuré) — dans crates/app-tauri/src/commands.rs, fn agent_send, boucle de pump du ReplyStream. AVANT chunk_from_event : `if let ReplyEvent::RateLimited { resets_at_ms } = &event { service.on_rate_limited(agent_id, node_id, conversation_id, *resets_at_ms); }` puis continuer le drain (non terminal). Récup node_id/agent_id : ajouter méthode meta_for_session(&SessionId)->Option<(AgentId,NodeId)> sur StructuredSessions (crates/application/src/terminal/registry.rs, jumeau de live_agents, lookup dans entries). conversation_id : passer None (acceptable LS7). Ce tap est DORMANT en composition B-2 mais à câbler pour forward-compat. Arc::clone(&state.session_limit_service) avant le thread::spawn, move dans le thread. 4. TAP NIVEAU 2 (PTY) — chemin ACTIF — dans commands.rs, fn launch_agent, branche PTY (if output.structured.is_none() + thread::spawn du pump d'octets) : a. Sélection §21.10-4 : appeler infrastructure::ratelimit::applies(&profile) avant d'armer. Besoin : exposer le AgentProfile (ou au minimum le RateLimitPattern) résolu sur LaunchAgentOutput (LaunchAgent::execute le résout déjà en interne — option la plus propre, zéro I/O). b. RateLimitParser::new(&pattern) (Option ⇒ regex invalide = pas de détecteur, jamais de panique), construit une fois par lancement, déplacé dans le thread de pump. c. Dans la boucle for chunk in stream, après send_output : String::from_utf8_lossy(&chunk) puis parser.detect(&text, clock.now_millis()). Sur Some(SessionLimit) ⇒ service.on_rate_limited(agent_id, node_id, conversation_id, limit.resets_at_ms). agent_id/node_id/conversation_id (request.conversation_id) déjà en main dans la commande ⇒ cloner avant thread::spawn. Besoin d'un Arc (réutiliser SystemClock). Anti-double-détection garantie par applies (structured_adapter.is_none()). Fragmentation PTY : best-effort par fragment pour LS7 (note QA). 5. DRAIN DU SCHEDULER — resume_rx drainé dans une tâche détachée spawné DANS AppState::build sur le patron EXACT de sweep_stalled : utiliser tauri::async_runtime::spawn (PAS tokio::spawn — build tourne dans le hook setup sans runtime ambiant). Boucle : while let Some(task) = resume_rx.recv().await { if let Err(e) = service.execute_resume(task).await { /* log best-effort */ } }. service (Arc) et resume_rx moved dans la closure. 6. COMMANDE TAURI cancel_resume — dans commands.rs : #[tauri::command] 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)) }. Enregistrer dans crates/app-tauri/src/lib.rs generate_handler![ … ] (à côté des commandes agent, ex. après change_agent_profile). ORDRE : (1)+(5) avec un AgentResumer stub d'abord (valide armement→canal→execute_resume→AgentResumed), puis (6) cancel_resume, puis (4) tap N2 PTY, puis (2) AppAgentResumer réel + registre ResumeContext alimenté par launch_agent, puis (3) tap N1 structuré + meta_for_session. CONFORMITÉ : domaine pur, regex/parse confinés infra, service pur-ports — ne rien violer. Pas de panique sur profil mal configuré / resume sans contexte (repli propre). === FIN CARTE === À la fin : `cargo build --workspace` puis `cargo test --workspace` et rapporte la SORTIE RÉELLE (succès/échec, nombres). Ajoute des tests unitaires côté app-tauri pour ce qui est testable sans Tauri runtime (ex. AppAgentResumer avec un LaunchAgent fake, meta_for_session sur StructuredSessions, la commande cancel_resume si possible). Rends via idea_reply : fichiers modifiés, décisions prises sur les points durs, et la sortie compilation/tests. - **Response:** LS7 terminé — workspace recompile, suite complète verte (83 binaires de test, 0 échec). Câblage suivi fidèlement, dans l'ordre recommandé. ## Fichiers modifiés **app-tauri (cœur du câblage)** - `crates/app-tauri/Cargo.toml` : `async-trait` déplacé/ajouté en `[dependencies]` (il n'était qu'en `[dev-dependencies]` ⇒ l'impl du port async `AgentResumer` côté lib ne compilait pas). - `crates/app-tauri/src/state.rs` : - **(1) Instanciation du service** dans `AppState::build`, après `input_mediator` et avant le bloc orchestrateur : `unbounded_channel::()`, `TokioScheduler` (clock partagé), `AppAgentResumer`, `SessionLimitService::new(clock, scheduler, events_port, resumer)`. Champ `pub session_limit_service: Arc` + `pub resume_contexts: ResumeContexts` ajoutés à `AppState` et au littéral final. - **(2) Port `AgentResumer` → `LaunchAgent`** : nouvel adapter `AppAgentResumer` (passerelle, même patron que `AppHandoffProvider`/`AppProviderSessionProvider`). Recompose un `LaunchAgentInput` à partir du `ResumeContext` lu + recalcule `mcp_runtime` via `mcp_endpoint::{idea_exe_path, mcp_endpoint}`, appelle le **même** `Arc`, puis injecte `RESUME_PROMPT` comme premier tour via le **portail d'entrée** (`InputMediator::enqueue`, pas de write brut, §20). Types `ResumeContext { project, rows, cols }` + alias `ResumeContexts = Arc>>`. - **(5) Drain du scheduler** : tâche détachée `tauri::async_runtime::spawn` (patron exact de `sweep_stalled`) : `while let Some(task) = resume_rx.recv().await { service.execute_resume(task).await }` (log best-effort sur erreur). - `crates/app-tauri/src/commands.rs` : - **(4) Tap niveau 2 (PTY, chemin actif)** dans `launch_agent` : sélection §21.10-4 via `infrastructure::ratelimit::applies(&output.profile)`, `RateLimitParser::new(pattern)` construit une fois, déplacé dans le thread de pump ; dans la boucle, avant `send_output`, `String::from_utf8_lossy` + `parser.detect(text, clock.now_millis())` ⇒ `service.on_rate_limited(agent_id, host_node_id, conversation_id, limit.resets_at_ms)`. Alimente aussi `state.resume_contexts` (projet cloné + rows/cols) à chaque lancement. - **(3) Tap niveau 1 (structuré, dormant mais câblé)** dans `agent_send` : `meta_for_session(&sid)` résout `(agent_id, node_id)` ; sur `ReplyEvent::RateLimited { resets_at_ms }` ⇒ `service.on_rate_limited(.., None, *resets_at_ms)` puis on continue le drain (`conversation_id = None`, acceptable LS7). - **(6) Commande `cancel_resume(agent_id) -> Result`** déléguant à `session_limit_service.cancel_resume`. - `crates/app-tauri/src/lib.rs` : `commands::cancel_resume` enregistré dans `generate_handler!`. **application (sanctionné par la carte)** - `crates/application/src/agent/lifecycle.rs` : champ `pub profile: Option` exposé sur `LaunchAgentOutput` (résolu déjà en interne, zéro I/O) ; `Some(profile.clone())` sur les 2 lancements effectifs (PTY + structuré), `None` sur les 3 réattaches (rebind/idempotent). - `crates/application/src/terminal/registry.rs` : `StructuredSessions::meta_for_session(&SessionId) -> Option<(AgentId, NodeId)>` (jumeau « inverse » de `live_agents`, lookup direct). **Tests** - `crates/app-tauri/tests/session_limit_wiring.rs` (nouveau) : `cancel_resume` no-op propre pour agent inconnu ; `on_rate_limited` arme une reprise annulable et publie `AgentRateLimited`→`AgentResumeScheduled`→(après cancel)`AgentResumeCancelled` sur le **vrai** bus via `AppState::build`. - `crates/application/tests/structured_registry_d1.rs` : test `meta_for_session` (résout agent+cellule, `None` si id inconnu/retiré). - Tests existants `dto_agents.rs` / `dto_chat.rs` mis à jour avec `profile: None` (nouveau champ du struct). ## Décisions sur les points durs - **Profil pour la sélection N2** : exposé en `Option` sur `LaunchAgentOutput` (option « la plus propre » de la carte) plutôt que dupliquer la règle ; app-tauri appelle `infrastructure::ratelimit::applies` ⇒ règle anti-double-détection N1/N2 reste l'unique source infra. `None` sur réattache (best-effort : pas d'armement, le tour n'a pas de nouvelle session). - **`AgentResumer::resume` sans `project_id`** : registre `ResumeContexts` partagé (composition root ↔ commande `launch_agent`) ; alimenté là où `Project`/taille sont en main. Resume « à l'aveugle » (contexte absent) ⇒ `Err(NotFound)` propre ⇒ `AgentResumed` non publié, jamais de panique. - **Injection `RESUME_PROMPT`** : via `InputMediator::enqueue` (portail d'écriture unique, §20), `PendingReply` lâché (reprise ≠ délégation corrélée). - **Horloge du tap N2** : `infrastructure::SystemClock` local au pump (l'`AppState` n'expose pas le clock). - **Limitations notées (best-effort LS7)** : fragmentation PTY (motif coupé entre 2 fragments) ⇒ raté best-effort par fragment ; le ré-abonnement du pump PTY n'est pas recâblé sur la reprise auto hors-commande (l'agent tourne, le front peut réattacher). ## Sortie compilation/tests (réelle) - `cargo build --workspace` : `Finished dev profile ... in 18.04s` ✅ (0 warning sur les crates touchées). - `cargo test --workspace` : **83 binaires `test result: ok`, 0 failed**, 0 erreur. Dont : - `tests/session_limit_wiring.rs` : `cancel_resume_is_a_clean_noop_for_unknown_agent` ok, `on_rate_limited_arms_a_cancellable_resume_over_the_real_bus` ok. - `application` `structured_meta_for_session_resolves_agent_and_node` ok (12 passed). - infrastructure 221 passed, app-tauri (events/pty/orchestrator/dto…) verts. Conformité hexagonale respectée : DTO/wire inchangés côté domaine, regex/parse confinés infra (`applies`/`RateLimitParser`), service pur-ports, aucun nouveau port domaine ni adapter infra (câblage de l'existant uniquement). - **Prompt:** Tâche LS8-backend — filet humain niveau 3 « armement par heure saisie » (ARCHITECTURE §21.1). Branche `feature/agent-session-limits`. Cadrage Architect (verdict B) ci-dessous, à implémenter tel quel. CONTEXTE : niveaux 1/2 détectent→planifient une reprise auto annulable. Niveau 3 : `agentRateLimitSuspected` est émis sans heure fiable ; il manque la boucle d'action pour que l'utilisateur saisisse l'heure et arme la MÊME reprise (annulable). DOMAINE : RIEN à ajouter — `domain::session_limit` a déjà `RateLimitSource::Human`, `plan_resume` (clampe à `now` si heure passée ⇒ reprise immédiate), `ResumePlan::Scheduled`. 1) APPLICATION — `SessionLimitService` (crates/application, cherche le module session_limit/service) : - Factorise la branche `ResumePlan::Scheduled` actuelle de `on_rate_limited` en une méthode privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` qui fait : publish `AgentRateLimited{Some(t)}` → `disarm` (dédoublonnage existant) → `scheduler.arm(ScheduledTask::ResumeAgent{...})` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled{fire_at_ms}`. `on_rate_limited` appelle cette privée pour son cas Scheduled (comportement identique, zéro régression). - Ajoute la méthode publique : ```rust /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset /// pour un agent en limite SUSPECTÉE. Construit une SessionLimit source `Human`, /// calcule le plan et arme la reprise EXACTEMENT comme la branche auto : mêmes /// événements, même dédoublonnage, même annulabilité via cancel_resume. pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64) ``` Corps : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `ResumePlan::Scheduled{fire_at_ms}` appelle `arm_scheduled(...)`. (Vérifie les noms/signatures exacts de `SessionLimit::new`, `plan_resume`, `ResumePlan` dans le domaine et aligne-toi dessus.) `execute_resume` et `cancel_resume` restent INCHANGÉS (l'armement humain s'annule/s'exécute par les mêmes voies : invariant = un seul mécanisme de reprise). 2) APP-TAURI — commande miroir de `cancel_resume` (crates/app-tauri/src/commands.rs) : ```rust #[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state: State<'_, AppState>) -> Result<(), ErrorDto> ``` Corps : `parse_agent_id` → résoudre côté backend (le front n'a que l'agent_id) : - `node_id` : via la registry des sessions vivantes (cherche un `node_for_agent`/équivalent sur `TerminalSessions`/`StructuredSessions` ; tu as déjà ajouté `meta_for_session` en LS7 — trouve/ajoute le lookup agent→node). Si pas de cellule vivante ⇒ `ErrorDto` NOT_FOUND/INVALID (la saisie n'a pas de cible). - `conversation_id` : best-effort via la session structurée de l'agent (`None` toléré). Puis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`. 3) ÉVÉNEMENTS : n'en crée AUCUN. `confirm_human_resume` réémet la paire existante `AgentRateLimited{Some}` + `AgentResumeScheduled{fire_at_ms}` (déjà relayées au front). Annulation/reprise via `AgentResumeCancelled`/`AgentResumed` inchangés. Compile (`cargo build`) et lance les tests des crates touchées. NE COMMIT PAS (Git tranche). Rends-moi via idea_reply : fichiers modifiés, signature exacte des méthodes domaine que tu as constatées (SessionLimit::new, plan_resume, ResumePlan), le lookup agent→node que tu as utilisé/ajouté, et le résultat de compilation/tests. QA écrira les tests ensuite — structure pour la testabilité. -- **Response:** LS8-backend (filet humain niveau 3) implémenté tel que cadré — verdict B. Build OK, tous tests verts, NON commité. ## Signatures domaine constatées (crates/domain/src/session_limit.rs) — RIEN ajouté - `RateLimitSource::{Structured, Pattern, Human}` (enum Copy). - `SessionLimit::new(resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource) -> Self` (const, ordre des args = (Option heure, now, source)). - `plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option) -> ResumePlan`. - `ResumePlan::Scheduled { fire_at_ms: i64, conversation_id: Option }` | `ResumePlan::HumanFallback`. Le clamp anti-passé (`fire_at_ms = resets_at_ms.max(now_ms)`) est dans `plan_resume` ⇒ heure saisie déjà passée = reprise immédiate, gratis. ## Fichiers modifiés 1. **crates/application/src/agent/session_limit.rs** - Factorisé la branche `ResumePlan::Scheduled` de `on_rate_limited` en privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms: Option)`. `on_rate_limited` l'appelle pour son cas Scheduled → comportement identique (les 9 tests `session_limit_service` existants passent inchangés). - ⚠️ **Écart assumé vs signature suggérée** : j'ai ajouté un 5e param `resets_at_ms: Option` à `arm_scheduled`. Raison : l'événement `AgentRateLimited` d'origine publie l'**heure de reset brute** (l'Option d'entrée), PAS `fire_at_ms` (qui est clampé). Les deux diffèrent si le reset est passé. Pour garantir le « zéro régression » exigé, j'ai propagé l'heure de reset brute au lieu de republier `Some(fire_at_ms)`. Les deux appelants passent `Some(...)`. - Ajouté la publique `pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64)` : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `Scheduled` appelle `arm_scheduled(..., Some(resets_at_ms))`. Cas `HumanFallback` traité en no-op défensif (inatteignable car `resets_at_ms` toujours `Some`). `execute_resume` et `cancel_resume` INCHANGÉS. 2. **crates/app-tauri/src/commands.rs** — ajout `#[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state) -> Result<(), ErrorDto>`, miroir de `cancel_resume`. 3. **crates/app-tauri/src/lib.rs** — `commands::set_resume_at` enregistrée juste après `commands::cancel_resume` dans le `generate_handler!`. ## Lookup agent→node utilisé (existant, rien ajouté) La registry des sessions vivantes expose déjà `node_for_agent`. `set_resume_at` résout : - `node_id = state.structured_sessions.node_for_agent(&id).or_else(|| state.terminal_sessions.node_for_agent(&id))` (structuré d'abord car il porte aussi la conv ; repli terminal/PTY). Aucune cellule vivante ⇒ `ErrorDto::from(AppError::NotFound(...))`. - `conversation_id` best-effort = `state.structured_sessions.session_for_agent(&id).and_then(|s| s.conversation_id())` (via `AgentSession::conversation_id() -> Option`, ports.rs:549). `None` toléré. Méthodes constatées dans crates/application/src/terminal/registry.rs : `TerminalSessions::node_for_agent` (l.175), `StructuredSessions::node_for_agent` (l.375), `session_for_agent`, `meta_for_session`. ## Événements : AUCUN créé. `confirm_human_resume` → `arm_scheduled` réémet la paire existante `AgentRateLimited{Some(t)}` + `AgentResumeScheduled{fire_at_ms}`. Annulation/reprise par les voies inchangées (`AgentResumeCancelled`/`AgentResumed`). ## Compilation / tests - `cargo build -p application -p app-tauri` : Finished, 0 erreur/0 warning. - `cargo test -p domain -p application` : tout vert (domain 39, session_limit_service 9, session_limit_t4 7, + le reste). - `cargo test -p app-tauri` : tout vert (dont wiring 43). ## Pour QA (testabilité) - `confirm_human_resume` testable 100 % sans I/O via les fakes existants (Clock/Scheduler/EventBus) déjà utilisés dans `tests/session_limit_service.rs`. Cas à couvrir : (a) heure future ⇒ `AgentRateLimited{Some}` puis `AgentResumeScheduled{fire_at_ms=resets_at_ms}` + ScheduleId mémorisé ; (b) heure passée ⇒ `fire_at_ms == now` (reprise immédiate) ; (c) dédoublonnage : un `confirm_human_resume` après un armement existant annule le précédent (1 seule entrée armée) ; (d) annulabilité : `cancel_resume` après `confirm_human_resume` publie `AgentResumeCancelled` ; (e) parité auto/humain : même séquence d'événements que `on_rate_limited` cas Scheduled. - `set_resume_at` : `node_for_agent` None ⇒ NOT_FOUND. \ No newline at end of file +- **Response:** LS8-backend (filet humain niveau 3) implémenté tel que cadré — verdict B. Build OK, tous tests verts, NON commité. ## Signatures domaine constatées (crates/domain/src/session_limit.rs) — RIEN ajouté - `RateLimitSource::{Structured, Pattern, Human}` (enum Copy). - `SessionLimit::new(resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource) -> Self` (const, ordre des args = (Option heure, now, source)). - `plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option) -> ResumePlan`. - `ResumePlan::Scheduled { fire_at_ms: i64, conversation_id: Option }` | `ResumePlan::HumanFallback`. Le clamp anti-passé (`fire_at_ms = resets_at_ms.max(now_ms)`) est dans `plan_resume` ⇒ heure saisie déjà passée = reprise immédiate, gratis. ## Fichiers modifiés 1. **crates/application/src/agent/session_limit.rs** - Factorisé la branche `ResumePlan::Scheduled` de `on_rate_limited` en privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms: Option)`. `on_rate_limited` l'appelle pour son cas Scheduled → comportement identique (les 9 tests `session_limit_service` existants passent inchangés). - ⚠️ **Écart assumé vs signature suggérée** : j'ai ajouté un 5e param `resets_at_ms: Option` à `arm_scheduled`. Raison : l'événement `AgentRateLimited` d'origine publie l'**heure de reset brute** (l'Option d'entrée), PAS `fire_at_ms` (qui est clampé). Les deux diffèrent si le reset est passé. Pour garantir le « zéro régression » exigé, j'ai propagé l'heure de reset brute au lieu de republier `Some(fire_at_ms)`. Les deux appelants passent `Some(...)`. - Ajouté la publique `pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64)` : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `Scheduled` appelle `arm_scheduled(..., Some(resets_at_ms))`. Cas `HumanFallback` traité en no-op défensif (inatteignable car `resets_at_ms` toujours `Some`). `execute_resume` et `cancel_resume` INCHANGÉS. 2. **crates/app-tauri/src/commands.rs** — ajout `#[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state) -> Result<(), ErrorDto>`, miroir de `cancel_resume`. 3. **crates/app-tauri/src/lib.rs** — `commands::set_resume_at` enregistrée juste après `commands::cancel_resume` dans le `generate_handler!`. ## Lookup agent→node utilisé (existant, rien ajouté) La registry des sessions vivantes expose déjà `node_for_agent`. `set_resume_at` résout : - `node_id = state.structured_sessions.node_for_agent(&id).or_else(|| state.terminal_sessions.node_for_agent(&id))` (structuré d'abord car il porte aussi la conv ; repli terminal/PTY). Aucune cellule vivante ⇒ `ErrorDto::from(AppError::NotFound(...))`. - `conversation_id` best-effort = `state.structured_sessions.session_for_agent(&id).and_then(|s| s.conversation_id())` (via `AgentSession::conversation_id() -> Option`, ports.rs:549). `None` toléré. Méthodes constatées dans crates/application/src/terminal/registry.rs : `TerminalSessions::node_for_agent` (l.175), `StructuredSessions::node_for_agent` (l.375), `session_for_agent`, `meta_for_session`. ## Événements : AUCUN créé. `confirm_human_resume` → `arm_scheduled` réémet la paire existante `AgentRateLimited{Some(t)}` + `AgentResumeScheduled{fire_at_ms}`. Annulation/reprise par les voies inchangées (`AgentResumeCancelled`/`AgentResumed`). ## Compilation / tests - `cargo build -p application -p app-tauri` : Finished, 0 erreur/0 warning. - `cargo test -p domain -p application` : tout vert (domain 39, session_limit_service 9, session_limit_t4 7, + le reste). - `cargo test -p app-tauri` : tout vert (dont wiring 43). ## Pour QA (testabilité) - `confirm_human_resume` testable 100 % sans I/O via les fakes existants (Clock/Scheduler/EventBus) déjà utilisés dans `tests/session_limit_service.rs`. Cas à couvrir : (a) heure future ⇒ `AgentRateLimited{Some}` puis `AgentResumeScheduled{fire_at_ms=resets_at_ms}` + ScheduleId mémorisé ; (b) heure passée ⇒ `fire_at_ms == now` (reprise immédiate) ; (c) dédoublonnage : un `confirm_human_resume` après un armement existant annule le précédent (1 seule entrée armée) ; (d) annulabilité : `cancel_resume` après `confirm_human_resume` publie `AgentResumeCancelled` ; (e) parité auto/humain : même séquence d'événements que `on_rate_limited` cas Scheduled. - `set_resume_at` : `node_for_agent` None ⇒ NOT_FOUND. +- **Prompt:** Feature « Agent orchestrateur du projet », branche active `feature/orchestrator-designation`. Implémente le **ticket T1 — Fondation domaine** (pur, aucune I/O). Cadrage Architect ci-dessous, à respecter strictement. ## Modèle On ne persiste que la DÉVIATION au défaut : un champ projet `orchestrator: Option`. `None` = défaut « plus ancien agent » (= `entries.first()`). `Some(id)` = désignation explicite (radio). Ça rend l'état illégal « 2 orchestrateurs » non représentable et donne la rétro-compat gratuitement. ## À implémenter (domaine pur uniquement, T1) 1. **VO `OrchestratorDesignation`** (dans `crates/domain/src/fileguard.rs` ou un petit module dédié, à toi de voir le plus cohérent) : - `none() -> Self` (aucun agent désigné, humain seul) - `of(agent: AgentId) -> Self` - `designated(&self) -> Option` 2. **Politique `fileguard.rs`** — élargis les signatures : - `is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool` : `User => true` (humain permanent), `Agent{id} => d.designated() == Some(id)`. (Perd le `const`, OK.) - `may_write_directly(who, res, d: &OrchestratorDesignation) -> bool` : si `res.is_project_context()` → `is_orchestrator(who, d)`, sinon `true`. - **Préserve le test single-writer existant** en lui passant `&OrchestratorDesignation::none()` : l'agent reste refusé, l'humain reste autorisé. 3. **`AgentManifest` (`crates/domain/src/agent.rs`)** : - Champ `orchestrator: Option` avec `#[serde(default, skip_serializing_if = "Option::is_none")]`. - `effective_orchestrator(&self) -> Option` = `self.orchestrator.or_else(|| self.entries.first().map(|e| e.agent_id))` (adapte au vrai nom du champ id de l'entrée). - `orchestrator_designation(&self) -> OrchestratorDesignation` (fold de l'effectif vers le VO). - `designate(&mut self, id) -> Result<(), DomainError>` : sémantique radio (écrase), valide que `id` appartient à `entries`. - `on_agent_deleted(&mut self, removed: AgentId)` : si `orchestrator == Some(removed)` → `None`. - Validation dans le constructeur (`AgentManifest::new` ou équivalent) : `orchestrator == Some(id)` ⇒ `id` doit être présent dans `entries` ; `None` toujours valide. - **Invariant à documenter** : les entrées du manifeste sont en ordre de création (`entries.first()` = plus ancien). Pas de timestamp à ajouter. 4. **`events.rs`** : nouveau `DomainEvent::OrchestratorChanged { project_id, orchestrator: Option }` (adapte aux champs/conventions des variants existants). ## Tests unitaires (dans les modules, T1) - single-writer préservé avec `none()` (agent refusé / humain autorisé) ; - agent désigné autorisé à écrire le contexte projet ; - défaut « plus ancien » quand `orchestrator == None` ; - succession lazy : `on_agent_deleted` du désigné → retombe sur plus ancien ; - `designate` écrase (radio) + rejette un agent absent ; - validation référentielle du constructeur. Respecte l'archi hexagonale (domaine sans I/O), SOLID, le style existant. Compile + teste le crate domaine (`cargo test -p domain` ou le vrai nom du package). Quand c'est vert, réponds via `idea_reply` avec : fichiers touchés, signatures finales exactes (pour que les tickets aval s'y accrochent), et la sortie réelle des tests. Si rouge, donne la sortie d'erreur réelle. +- **Response:** ## T1 — Fondation domaine : VERT ✅ `cargo test -p domain` passe intégralement : **lib 175 passed; 0 failed** (dont 11 nouveaux tests), + toutes les suites d'intégration du crate (entities, serde_roundtrip, etc.) restent vertes. Aucune I/O introduite, domaine pur. ### Fichiers touchés (domaine pur uniquement) - `crates/domain/src/fileguard.rs` — VO `OrchestratorDesignation` + élargissement des signatures de politique + tests. - `crates/domain/src/agent.rs` — champ `orchestrator` sur `AgentManifest` + méthodes + tests (module `orchestrator_tests`). - `crates/domain/src/events.rs` — variant `DomainEvent::OrchestratorChanged` + test. - `crates/domain/src/lib.rs` — réexport de `OrchestratorDesignation`. ### Signatures finales exactes (points d'accroche pour les tickets aval) **`fileguard.rs`** (réexportées depuis `domain::`) : ```rust pub struct OrchestratorDesignation(/* private Option */); impl OrchestratorDesignation { pub const fn none() -> Self; pub const fn of(agent: AgentId) -> Self; pub const fn designated(&self) -> Option; } // dérive Debug, Clone, Copy, PartialEq, Eq pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool; pub fn may_write_directly( who: ConversationParty, res: &GuardedResource, d: &OrchestratorDesignation, ) -> bool; ``` Sémantique : `User => true` (humain permanent), `Agent{id} => d.designated() == Some(id)`. `may_write_directly` ⇒ `is_orchestrator` si `res.is_project_context()`, sinon `true`. Les deux perdent `const` (le `match` sur `who` n'est pas const-compatible ici). **`agent.rs` — `AgentManifest`** (nouveau champ `pub orchestrator: Option` avec `#[serde(default, skip_serializing_if = "Option::is_none")]`) : ```rust // constructeur historique inchangé (orchestrator = None, toujours valide) pub fn new(version: u32, entries: Vec) -> Result; // constructeur validant la désignation explicite (intégrité référentielle) pub fn with_orchestrator( version: u32, entries: Vec, orchestrator: Option, ) -> Result; pub fn effective_orchestrator(&self) -> Option; // designation || entries.first() pub fn orchestrator_designation(&self) -> OrchestratorDesignation; // fold de l'effectif vers le VO pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError>; // radio, écrase, valide l'appartenance pub fn on_agent_deleted(&mut self, removed: AgentId); // Some(removed) -> None (succession lazy) ``` Choix d'implémentation à noter : `AgentManifest::new` garde **sa signature** (orchestrator = None) ; la validation référentielle du constructeur demandée est portée par `with_orchestrator` (`new` y délègue avec `None`). Ça évite de casser tous les appelants `AgentManifest::new(version, entries)` existants. La validation : `orchestrator == Some(id)` ⇒ `id ∈ entries`, sinon `DomainError::InconsistentManifest`. Invariant d'ordre documenté sur le doccomment du struct : `entries` en ordre de création, `entries.first()` = plus ancien, pas de timestamp. **`events.rs`** : ```rust DomainEvent::OrchestratorChanged { project_id: ProjectId, orchestrator: Option, } ``` ### Tests couverts (tous verts) - single-writer préservé avec `none()` (agent refusé / humain autorisé) ; - agent désigné autorisé à écrire le contexte projet (+ autre agent refusé, humain toujours OK) — testé côté `fileguard` ET via `AgentManifest::orchestrator_designation()` ; - défaut « plus ancien » quand `orchestrator == None` (+ manifeste vide ⇒ `None`) ; - succession lazy : `on_agent_deleted` du désigné → `None` → retombe sur plus ancien ; no-op si l'agent supprimé n'était pas le désigné ; - `designate` écrase (radio) + rejette un agent absent (et préserve la désignation valide précédente) ; - validation référentielle du constructeur (`with_orchestrator`) ; - `OrchestratorChanged` construit/compare. ### ⚠️ Impact aval à prévoir (hors scope T1, à câbler par les tickets suivants) La couche **infrastructure ne compile plus tel quel** — c'est attendu, le câblage est aval : 1. `crates/infrastructure/src/fileguard/mod.rs:93` appelle encore `may_write_directly(who, &res)` (2 args). L'adaptateur `RwFileGuard` doit recevoir/threader une `OrchestratorDesignation` (probablement injectée depuis le manifeste courant) et passer 3 args. 2. Le nouveau champ `orchestrator` casse les **littéraux de struct exhaustifs** `AgentManifest { version, entries }` (≈ infra `store/context.rs`, application `context_guard.rs`/tests, `app-tauri/state.rs`, plusieurs tests). Fix mécanique : ajouter `orchestrator: None` ou `..Default::default()`. `Default` est dérivé donc `..Default::default()` marche. `cargo test -p domain` étant le périmètre demandé, le crate domaine est vert et autonome ; ces deux points sont la surface de câblage des tickets T2+. +- **Prompt:** Suite de la feature orchestrateur, branche `feature/orchestrator-designation`. T1 (domaine) est vert. Implémente maintenant **T2 → T5** (infra + application backend) pour rétablir un workspace qui compile et tester. Suis le cadrage Architect, notamment l'**Alternative A** retenue. ### T2 — Infra : guard = verrou pur + serde rétro-compatible (dépend T1) - **Alternative A (RETENUE)** : `RwFileGuard::acquire_write` (`crates/infrastructure/src/fileguard/mod.rs`) NE fait plus le check d'identité orchestrateur. Il redevient un **verrou pur** (sérialise les writers, comme un rwlock). Donc retire l'appel `may_write_directly` côté guard (la ligne ~93). L'autorisation single-writer remonte dans `ProposeContext` (T3). `GuardError::Forbidden` n'est plus émis par le guard — vérifie ce que ça implique pour le port/les tests du guard (déplace/retire le test « Forbidden » qui n'a plus lieu d'être à ce niveau, documente que le guard est désormais un lock pur). Si la signature du port `FileGuard::acquire_write` portait un paramètre lié à l'identité, garde-la cohérente. - **Serde** : corrige tous les littéraux exhaustifs `AgentManifest { version, entries }` cassés par le nouveau champ (ajoute `orchestrator: None` ou `..Default::default()`). Test round-trip : un `agents.json` legacy SANS le champ `orchestrator` se désérialise → `None` → `effective_orchestrator()` = plus ancien. ### T3 — Application : autorisation propose (cœur MCP) (dépend T1, T2) Dans `ProposeContext` (`crates/application/.../context_guard.rs`), branche globale (target = None) : ``` let manifest = contexts.load_manifest(project).await?; let d = manifest.orchestrator_designation(); if may_write_directly(requester, &GuardedResource::ProjectContext, &d) { let _lease = guard.acquire_write(requester, ProjectContext).await?; // sérialise fs.write(project_context_file, content) -> Written } else { file_proposal(...) -> Proposed { path } // inchangé } ``` Conséquence : quand l'appelant `idea_context_propose` (sans target) EST l'orchestrateur désigné, l'écriture devient DIRECTE ; sinon proposition ; l'humain écrit toujours direct. Tests : agent désigné → write direct ; agent non-désigné → proposition ; humain → direct. ### T4 — Application : défaut + succession (dépend T1) - `DeleteAgent::execute` (`lifecycle.rs`) : après filtrage des entrées, appelle `manifest.on_agent_deleted(removed)` avant `save_manifest` ; émets `DomainEvent::OrchestratorChanged` si `effective_orchestrator()` a changé. - Défaut (Create scratch + template) : pas de logique à ajouter (modèle paresseux), mais ajoute les tests qui figent « 1er agent créé = orchestrateur effectif » et l'émission de `OrchestratorChanged` si l'effectif passe de None→1er. - Tests succession : désigné explicite supprimé → retombe sur plus ancien ; non-désigné supprimé → repointage paresseux. ### T5 — Application : `SetOrchestrator` + ListAgents output (dépend T1) - Nouveau use case `SetOrchestrator { project, agent_id }` : `load_manifest` → `manifest.designate(agent_id)` (radio, écrase) → `save_manifest` → publie `OrchestratorChanged`. `NotFound`/erreur domaine si l'agent n'existe pas. - Étends `ListAgentsOutput` avec `orchestrator: Option` (= `effective_orchestrator()`). - Tests (store mock) : écrasement radio, agent inconnu → erreur, output porte l'effectif. Respecte l'archi hexagonale, SOLID, le style existant, aucun nouveau port (compose FileGuard / AgentContextStore / EventBus existants). Compile et teste les crates infra + application (`cargo test -p infrastructure -p application` ou vrais noms). Quand vert, réponds via `idea_reply` : fichiers touchés, signatures publiques finales de `SetOrchestrator` et du champ ajouté à `ListAgentsOutput` (accroche pour T6), et la sortie réelle des tests. Si rouge, donne l'erreur réelle. +- **Prompt:** Tâche ciblée sur la branche courante `feature/orchestrator-designation`. Contexte: - Git a décidé de finir le chantier courant `orchestrator-designation` sur cette branche, sans switch/stash. - Main ne code pas; tu es chargé de la correction backend Rust. - Tests ciblés verts: - `cargo test -p infrastructure input --lib`: 35 passed. - `cargo test -p application --test orchestrator_service`: 45 passed. - Frontend vert: - `cd frontend && npx vitest run`: 41 files, 384 tests passed. - `cd frontend && npx tsc --noEmit`: OK. Problème à corriger: `cargo test --workspace` échoue à la compilation sur `crates/application/src/orchestrator/context_guard.rs`. Sortie réelle: ```text error[E0061]: this function takes 3 arguments but 2 arguments were supplied --> crates/application/src/orchestrator/context_guard.rs:449:17 | 449 | if !may_write_directly(who, &res) { | ^^^^^^^^^^^^^^^^^^----------- argument #3 of type `&OrchestratorDesignation` is missing error[E0560]: struct `ManifestEntry` has no field named `orchestrator` --> crates/application/src/orchestrator/context_guard.rs:616:21 | 616 | orchestrator: None, | ^^^^^^^^^^^^ `ManifestEntry` does not have this field error[E0063]: missing field `orchestrator` in initializer of `AgentManifest` --> crates/application/src/orchestrator/context_guard.rs:613:23 | 613 | manifest: AgentManifest { | ^^^^^^^^^^^^^ missing `orchestrator` ``` Attendu: - Adapter `context_guard.rs` au nouveau modèle `AgentManifest { version, entries, orchestrator }` et à `may_write_directly(resource, designation)` sans contourner l'architecture. - Supprimer l'import inutilisé si nécessaire. - Garder le domaine propriétaire des règles de désignation orchestrateur. - Ne touche pas aux fichiers `.ideai/*` runtime. Validation minimale à exécuter après correction: 1. `cargo test -p application --test orchestrator_service` 2. `cargo test --workspace` si raisonnable. Réponds avec les fichiers modifiés et les commandes/test outputs réels. Si cette tâche arrive via IdeA, utilise impérativement `idea_reply`. +- **Response:** Correction backend appliquée sur `crates/application/src/orchestrator/context_guard.rs`. Modifications: - `ProposeContext` charge maintenant `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle la règle domaine `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)` avant toute écriture globale. - Le `FileGuard` reste un verrou de sérialisation: la décision orchestrateur/proposition reste dans le domaine via `may_write_directly`. - Le commentaire obsolète qui disait que le guard renvoyait `Forbidden` pour les non-orchestrateurs a été mis à jour. - Les tests locaux de `context_guard.rs` ont été adaptés au modèle `AgentManifest { version, entries, orchestrator }`. - Le fake `TestGuard` ne réimplémente plus l’autorisation; il reflète `infrastructure::RwFileGuard` comme verrou pur. Fichier modifié par moi: - `crates/application/src/orchestrator/context_guard.rs` Validations exécutées: 1. `cargo fmt --all && cargo test -p application --test orchestrator_service` - Résultat: OK - Sortie utile: `45 passed; 0 failed; 0 ignored` - Warning existant: `method writes is never used` dans `crates/application/tests/orchestrator_service.rs:1642`. 2. `cargo test --workspace` - Résultat: compilation passée jusqu’à l’exécution des tests, puis échec dans `app-tauri` sur tests loopback Unix socket sous le sandbox courant. - Sortie utile: `app-tauri` lib: `39 passed; 8 failed`. - Échecs: `mcp_bridge::tests::end_to_end_over_real_loopback`, `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, et les tests `state::mcp_e2e_loopback_tests::*`. - Cause observée: `PermissionDenied` / `Operation not permitted` lors du bind ou de la pose de socket sous `/run/user/1000/idea-mcp/*.sock`, par exemple `bind test listener: Os { code: 1, kind: PermissionDenied, message: "Operation not permitted" }`. 3. `cargo test -p application` - Résultat: OK - Sortie utile: tous les tests application sont verts, dont `orchestrator::context_guard`. - Extraits: lib `41 passed`; `orchestrator_service` `45 passed`; suite application complète terminée avec succès. Note: je n’ai pas touché aux fichiers `.ideai/*` runtime. Le `git status` montre beaucoup de fichiers déjà modifiés dans le workspace, dont `.ideai/*`; je les ai laissés tels quels. \ No newline at end of file diff --git a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl index 7e89871..5af6899 100644 --- a/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl +++ b/.ideai/conversations/d5068bc8-786e-0428-1d14-83b93961a15e/log.jsonl @@ -57,3 +57,8 @@ {"id":"3e582fd2-5935-4aa1-b7bf-467e06ef58b4","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781640615842,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LS7 terminé — workspace recompile, suite complète verte (83 binaires de test, 0 échec). Câblage suivi fidèlement, dans l'ordre recommandé.\n\n## Fichiers modifiés\n\n**app-tauri (cœur du câblage)**\n- `crates/app-tauri/Cargo.toml` : `async-trait` déplacé/ajouté en `[dependencies]` (il n'était qu'en `[dev-dependencies]` ⇒ l'impl du port async `AgentResumer` côté lib ne compilait pas).\n- `crates/app-tauri/src/state.rs` :\n - **(1) Instanciation du service** dans `AppState::build`, après `input_mediator` et avant le bloc orchestrateur : `unbounded_channel::()`, `TokioScheduler` (clock partagé), `AppAgentResumer`, `SessionLimitService::new(clock, scheduler, events_port, resumer)`. Champ `pub session_limit_service: Arc` + `pub resume_contexts: ResumeContexts` ajoutés à `AppState` et au littéral final.\n - **(2) Port `AgentResumer` → `LaunchAgent`** : nouvel adapter `AppAgentResumer` (passerelle, même patron que `AppHandoffProvider`/`AppProviderSessionProvider`). Recompose un `LaunchAgentInput` à partir du `ResumeContext` lu + recalcule `mcp_runtime` via `mcp_endpoint::{idea_exe_path, mcp_endpoint}`, appelle le **même** `Arc`, puis injecte `RESUME_PROMPT` comme premier tour via le **portail d'entrée** (`InputMediator::enqueue`, pas de write brut, §20). Types `ResumeContext { project, rows, cols }` + alias `ResumeContexts = Arc>>`.\n - **(5) Drain du scheduler** : tâche détachée `tauri::async_runtime::spawn` (patron exact de `sweep_stalled`) : `while let Some(task) = resume_rx.recv().await { service.execute_resume(task).await }` (log best-effort sur erreur).\n- `crates/app-tauri/src/commands.rs` :\n - **(4) Tap niveau 2 (PTY, chemin actif)** dans `launch_agent` : sélection §21.10-4 via `infrastructure::ratelimit::applies(&output.profile)`, `RateLimitParser::new(pattern)` construit une fois, déplacé dans le thread de pump ; dans la boucle, avant `send_output`, `String::from_utf8_lossy` + `parser.detect(text, clock.now_millis())` ⇒ `service.on_rate_limited(agent_id, host_node_id, conversation_id, limit.resets_at_ms)`. Alimente aussi `state.resume_contexts` (projet cloné + rows/cols) à chaque lancement.\n - **(3) Tap niveau 1 (structuré, dormant mais câblé)** dans `agent_send` : `meta_for_session(&sid)` résout `(agent_id, node_id)` ; sur `ReplyEvent::RateLimited { resets_at_ms }` ⇒ `service.on_rate_limited(.., None, *resets_at_ms)` puis on continue le drain (`conversation_id = None`, acceptable LS7).\n - **(6) Commande `cancel_resume(agent_id) -> Result`** déléguant à `session_limit_service.cancel_resume`.\n- `crates/app-tauri/src/lib.rs` : `commands::cancel_resume` enregistré dans `generate_handler!`.\n\n**application (sanctionné par la carte)**\n- `crates/application/src/agent/lifecycle.rs` : champ `pub profile: Option` exposé sur `LaunchAgentOutput` (résolu déjà en interne, zéro I/O) ; `Some(profile.clone())` sur les 2 lancements effectifs (PTY + structuré), `None` sur les 3 réattaches (rebind/idempotent).\n- `crates/application/src/terminal/registry.rs` : `StructuredSessions::meta_for_session(&SessionId) -> Option<(AgentId, NodeId)>` (jumeau « inverse » de `live_agents`, lookup direct).\n\n**Tests**\n- `crates/app-tauri/tests/session_limit_wiring.rs` (nouveau) : `cancel_resume` no-op propre pour agent inconnu ; `on_rate_limited` arme une reprise annulable et publie `AgentRateLimited`→`AgentResumeScheduled`→(après cancel)`AgentResumeCancelled` sur le **vrai** bus via `AppState::build`.\n- `crates/application/tests/structured_registry_d1.rs` : test `meta_for_session` (résout agent+cellule, `None` si id inconnu/retiré).\n- Tests existants `dto_agents.rs` / `dto_chat.rs` mis à jour avec `profile: None` (nouveau champ du struct).\n\n## Décisions sur les points durs\n- **Profil pour la sélection N2** : exposé en `Option` sur `LaunchAgentOutput` (option « la plus propre » de la carte) plutôt que dupliquer la règle ; app-tauri appelle `infrastructure::ratelimit::applies` ⇒ règle anti-double-détection N1/N2 reste l'unique source infra. `None` sur réattache (best-effort : pas d'armement, le tour n'a pas de nouvelle session).\n- **`AgentResumer::resume` sans `project_id`** : registre `ResumeContexts` partagé (composition root ↔ commande `launch_agent`) ; alimenté là où `Project`/taille sont en main. Resume « à l'aveugle » (contexte absent) ⇒ `Err(NotFound)` propre ⇒ `AgentResumed` non publié, jamais de panique.\n- **Injection `RESUME_PROMPT`** : via `InputMediator::enqueue` (portail d'écriture unique, §20), `PendingReply` lâché (reprise ≠ délégation corrélée).\n- **Horloge du tap N2** : `infrastructure::SystemClock` local au pump (l'`AppState` n'expose pas le clock).\n- **Limitations notées (best-effort LS7)** : fragmentation PTY (motif coupé entre 2 fragments) ⇒ raté best-effort par fragment ; le ré-abonnement du pump PTY n'est pas recâblé sur la reprise auto hors-commande (l'agent tourne, le front peut réattacher).\n\n## Sortie compilation/tests (réelle)\n- `cargo build --workspace` : `Finished dev profile ... in 18.04s` ✅ (0 warning sur les crates touchées).\n- `cargo test --workspace` : **83 binaires `test result: ok`, 0 failed**, 0 erreur. Dont :\n - `tests/session_limit_wiring.rs` : `cancel_resume_is_a_clean_noop_for_unknown_agent` ok, `on_rate_limited_arms_a_cancellable_resume_over_the_real_bus` ok.\n - `application` `structured_meta_for_session_resolves_agent_and_node` ok (12 passed).\n - infrastructure 221 passed, app-tauri (events/pty/orchestrator/dto…) verts.\n\nConformité hexagonale respectée : DTO/wire inchangés côté domaine, regex/parse confinés infra (`applies`/`RateLimitParser`), service pur-ports, aucun nouveau port domaine ni adapter infra (câblage de l'existant uniquement)."} {"id":"1f44fef5-9feb-4ba7-88cf-529df0216e83","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781676572144,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche LS8-backend — filet humain niveau 3 « armement par heure saisie » (ARCHITECTURE §21.1). Branche `feature/agent-session-limits`. Cadrage Architect (verdict B) ci-dessous, à implémenter tel quel.\n\nCONTEXTE : niveaux 1/2 détectent→planifient une reprise auto annulable. Niveau 3 : `agentRateLimitSuspected` est émis sans heure fiable ; il manque la boucle d'action pour que l'utilisateur saisisse l'heure et arme la MÊME reprise (annulable). DOMAINE : RIEN à ajouter — `domain::session_limit` a déjà `RateLimitSource::Human`, `plan_resume` (clampe à `now` si heure passée ⇒ reprise immédiate), `ResumePlan::Scheduled`.\n\n1) APPLICATION — `SessionLimitService` (crates/application, cherche le module session_limit/service) :\n - Factorise la branche `ResumePlan::Scheduled` actuelle de `on_rate_limited` en une méthode privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id)` qui fait : publish `AgentRateLimited{Some(t)}` → `disarm` (dédoublonnage existant) → `scheduler.arm(ScheduledTask::ResumeAgent{...})` → mémoriser le `ScheduleId` → publish `AgentResumeScheduled{fire_at_ms}`. `on_rate_limited` appelle cette privée pour son cas Scheduled (comportement identique, zéro régression).\n - Ajoute la méthode publique :\n ```rust\n /// (d) Filet humain (§21.1 niveau 3). L'utilisateur a saisi l'heure de reset\n /// pour un agent en limite SUSPECTÉE. Construit une SessionLimit source `Human`,\n /// calcule le plan et arme la reprise EXACTEMENT comme la branche auto : mêmes\n /// événements, même dédoublonnage, même annulabilité via cancel_resume.\n pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64)\n ```\n Corps : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `ResumePlan::Scheduled{fire_at_ms}` appelle `arm_scheduled(...)`. (Vérifie les noms/signatures exacts de `SessionLimit::new`, `plan_resume`, `ResumePlan` dans le domaine et aligne-toi dessus.) `execute_resume` et `cancel_resume` restent INCHANGÉS (l'armement humain s'annule/s'exécute par les mêmes voies : invariant = un seul mécanisme de reprise).\n\n2) APP-TAURI — commande miroir de `cancel_resume` (crates/app-tauri/src/commands.rs) :\n ```rust\n #[tauri::command]\n pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state: State<'_, AppState>) -> Result<(), ErrorDto>\n ```\n Corps : `parse_agent_id` → résoudre côté backend (le front n'a que l'agent_id) :\n - `node_id` : via la registry des sessions vivantes (cherche un `node_for_agent`/équivalent sur `TerminalSessions`/`StructuredSessions` ; tu as déjà ajouté `meta_for_session` en LS7 — trouve/ajoute le lookup agent→node). Si pas de cellule vivante ⇒ `ErrorDto` NOT_FOUND/INVALID (la saisie n'a pas de cible).\n - `conversation_id` : best-effort via la session structurée de l'agent (`None` toléré).\n Puis `state.session_limit_service.confirm_human_resume(id, node_id, conv_id, resets_at_ms)`. Enregistre la commande dans `lib.rs` à côté de `cancel_resume`.\n\n3) ÉVÉNEMENTS : n'en crée AUCUN. `confirm_human_resume` réémet la paire existante `AgentRateLimited{Some}` + `AgentResumeScheduled{fire_at_ms}` (déjà relayées au front). Annulation/reprise via `AgentResumeCancelled`/`AgentResumed` inchangés.\n\nCompile (`cargo build`) et lance les tests des crates touchées. NE COMMIT PAS (Git tranche). Rends-moi via idea_reply : fichiers modifiés, signature exacte des méthodes domaine que tu as constatées (SessionLimit::new, plan_resume, ResumePlan), le lookup agent→node que tu as utilisé/ajouté, et le résultat de compilation/tests. QA écrira les tests ensuite — structure pour la testabilité."} {"id":"21ed91d7-af2d-4537-9952-8236c7faafe4","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781676757717,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"LS8-backend (filet humain niveau 3) implémenté tel que cadré — verdict B. Build OK, tous tests verts, NON commité.\n\n## Signatures domaine constatées (crates/domain/src/session_limit.rs) — RIEN ajouté\n- `RateLimitSource::{Structured, Pattern, Human}` (enum Copy).\n- `SessionLimit::new(resets_at_ms: Option, detected_at_ms: i64, source: RateLimitSource) -> Self` (const, ordre des args = (Option heure, now, source)).\n- `plan_resume(now_ms: i64, limit: &SessionLimit, conversation_id: Option) -> ResumePlan`.\n- `ResumePlan::Scheduled { fire_at_ms: i64, conversation_id: Option }` | `ResumePlan::HumanFallback`. Le clamp anti-passé (`fire_at_ms = resets_at_ms.max(now_ms)`) est dans `plan_resume` ⇒ heure saisie déjà passée = reprise immédiate, gratis.\n\n## Fichiers modifiés\n1. **crates/application/src/agent/session_limit.rs**\n - Factorisé la branche `ResumePlan::Scheduled` de `on_rate_limited` en privée `fn arm_scheduled(&self, agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms: Option)`. `on_rate_limited` l'appelle pour son cas Scheduled → comportement identique (les 9 tests `session_limit_service` existants passent inchangés).\n - ⚠️ **Écart assumé vs signature suggérée** : j'ai ajouté un 5e param `resets_at_ms: Option` à `arm_scheduled`. Raison : l'événement `AgentRateLimited` d'origine publie l'**heure de reset brute** (l'Option d'entrée), PAS `fire_at_ms` (qui est clampé). Les deux diffèrent si le reset est passé. Pour garantir le « zéro régression » exigé, j'ai propagé l'heure de reset brute au lieu de republier `Some(fire_at_ms)`. Les deux appelants passent `Some(...)`.\n - Ajouté la publique `pub fn confirm_human_resume(&self, agent_id: AgentId, node_id: NodeId, conversation_id: Option, resets_at_ms: i64)` : `SessionLimit::new(Some(resets_at_ms), now, RateLimitSource::Human)` → `plan_resume` → sur `Scheduled` appelle `arm_scheduled(..., Some(resets_at_ms))`. Cas `HumanFallback` traité en no-op défensif (inatteignable car `resets_at_ms` toujours `Some`). `execute_resume` et `cancel_resume` INCHANGÉS.\n2. **crates/app-tauri/src/commands.rs** — ajout `#[tauri::command] pub async fn set_resume_at(agent_id: String, resets_at_ms: i64, state) -> Result<(), ErrorDto>`, miroir de `cancel_resume`.\n3. **crates/app-tauri/src/lib.rs** — `commands::set_resume_at` enregistrée juste après `commands::cancel_resume` dans le `generate_handler!`.\n\n## Lookup agent→node utilisé (existant, rien ajouté)\nLa registry des sessions vivantes expose déjà `node_for_agent`. `set_resume_at` résout :\n- `node_id = state.structured_sessions.node_for_agent(&id).or_else(|| state.terminal_sessions.node_for_agent(&id))` (structuré d'abord car il porte aussi la conv ; repli terminal/PTY). Aucune cellule vivante ⇒ `ErrorDto::from(AppError::NotFound(...))`.\n- `conversation_id` best-effort = `state.structured_sessions.session_for_agent(&id).and_then(|s| s.conversation_id())` (via `AgentSession::conversation_id() -> Option`, ports.rs:549). `None` toléré.\nMéthodes constatées dans crates/application/src/terminal/registry.rs : `TerminalSessions::node_for_agent` (l.175), `StructuredSessions::node_for_agent` (l.375), `session_for_agent`, `meta_for_session`.\n\n## Événements : AUCUN créé. `confirm_human_resume` → `arm_scheduled` réémet la paire existante `AgentRateLimited{Some(t)}` + `AgentResumeScheduled{fire_at_ms}`. Annulation/reprise par les voies inchangées (`AgentResumeCancelled`/`AgentResumed`).\n\n## Compilation / tests\n- `cargo build -p application -p app-tauri` : Finished, 0 erreur/0 warning.\n- `cargo test -p domain -p application` : tout vert (domain 39, session_limit_service 9, session_limit_t4 7, + le reste).\n- `cargo test -p app-tauri` : tout vert (dont wiring 43).\n\n## Pour QA (testabilité)\n- `confirm_human_resume` testable 100 % sans I/O via les fakes existants (Clock/Scheduler/EventBus) déjà utilisés dans `tests/session_limit_service.rs`. Cas à couvrir : (a) heure future ⇒ `AgentRateLimited{Some}` puis `AgentResumeScheduled{fire_at_ms=resets_at_ms}` + ScheduleId mémorisé ; (b) heure passée ⇒ `fire_at_ms == now` (reprise immédiate) ; (c) dédoublonnage : un `confirm_human_resume` après un armement existant annule le précédent (1 seule entrée armée) ; (d) annulabilité : `cancel_resume` après `confirm_human_resume` publie `AgentResumeCancelled` ; (e) parité auto/humain : même séquence d'événements que `on_rate_limited` cas Scheduled.\n- `set_resume_at` : `node_for_agent` None ⇒ NOT_FOUND."} +{"id":"a28300c2-dd9d-4fc0-a3fb-070b73857d30","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781735797521,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Feature « Agent orchestrateur du projet », branche active `feature/orchestrator-designation`. Implémente le **ticket T1 — Fondation domaine** (pur, aucune I/O). Cadrage Architect ci-dessous, à respecter strictement.\n\n## Modèle\nOn ne persiste que la DÉVIATION au défaut : un champ projet `orchestrator: Option`. `None` = défaut « plus ancien agent » (= `entries.first()`). `Some(id)` = désignation explicite (radio). Ça rend l'état illégal « 2 orchestrateurs » non représentable et donne la rétro-compat gratuitement.\n\n## À implémenter (domaine pur uniquement, T1)\n\n1. **VO `OrchestratorDesignation`** (dans `crates/domain/src/fileguard.rs` ou un petit module dédié, à toi de voir le plus cohérent) :\n - `none() -> Self` (aucun agent désigné, humain seul)\n - `of(agent: AgentId) -> Self`\n - `designated(&self) -> Option`\n\n2. **Politique `fileguard.rs`** — élargis les signatures :\n - `is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool` : `User => true` (humain permanent), `Agent{id} => d.designated() == Some(id)`. (Perd le `const`, OK.)\n - `may_write_directly(who, res, d: &OrchestratorDesignation) -> bool` : si `res.is_project_context()` → `is_orchestrator(who, d)`, sinon `true`.\n - **Préserve le test single-writer existant** en lui passant `&OrchestratorDesignation::none()` : l'agent reste refusé, l'humain reste autorisé.\n\n3. **`AgentManifest` (`crates/domain/src/agent.rs`)** :\n - Champ `orchestrator: Option` avec `#[serde(default, skip_serializing_if = \"Option::is_none\")]`.\n - `effective_orchestrator(&self) -> Option` = `self.orchestrator.or_else(|| self.entries.first().map(|e| e.agent_id))` (adapte au vrai nom du champ id de l'entrée).\n - `orchestrator_designation(&self) -> OrchestratorDesignation` (fold de l'effectif vers le VO).\n - `designate(&mut self, id) -> Result<(), DomainError>` : sémantique radio (écrase), valide que `id` appartient à `entries`.\n - `on_agent_deleted(&mut self, removed: AgentId)` : si `orchestrator == Some(removed)` → `None`.\n - Validation dans le constructeur (`AgentManifest::new` ou équivalent) : `orchestrator == Some(id)` ⇒ `id` doit être présent dans `entries` ; `None` toujours valide.\n - **Invariant à documenter** : les entrées du manifeste sont en ordre de création (`entries.first()` = plus ancien). Pas de timestamp à ajouter.\n\n4. **`events.rs`** : nouveau `DomainEvent::OrchestratorChanged { project_id, orchestrator: Option }` (adapte aux champs/conventions des variants existants).\n\n## Tests unitaires (dans les modules, T1)\n- single-writer préservé avec `none()` (agent refusé / humain autorisé) ;\n- agent désigné autorisé à écrire le contexte projet ;\n- défaut « plus ancien » quand `orchestrator == None` ;\n- succession lazy : `on_agent_deleted` du désigné → retombe sur plus ancien ;\n- `designate` écrase (radio) + rejette un agent absent ;\n- validation référentielle du constructeur.\n\nRespecte l'archi hexagonale (domaine sans I/O), SOLID, le style existant. Compile + teste le crate domaine (`cargo test -p domain` ou le vrai nom du package). Quand c'est vert, réponds via `idea_reply` avec : fichiers touchés, signatures finales exactes (pour que les tickets aval s'y accrochent), et la sortie réelle des tests. Si rouge, donne la sortie d'erreur réelle."} +{"id":"27a0f25c-115a-4744-b200-446c82e134a9","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781736023050,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"## T1 — Fondation domaine : VERT ✅\n\n`cargo test -p domain` passe intégralement : **lib 175 passed; 0 failed** (dont 11 nouveaux tests), + toutes les suites d'intégration du crate (entities, serde_roundtrip, etc.) restent vertes. Aucune I/O introduite, domaine pur.\n\n### Fichiers touchés (domaine pur uniquement)\n- `crates/domain/src/fileguard.rs` — VO `OrchestratorDesignation` + élargissement des signatures de politique + tests.\n- `crates/domain/src/agent.rs` — champ `orchestrator` sur `AgentManifest` + méthodes + tests (module `orchestrator_tests`).\n- `crates/domain/src/events.rs` — variant `DomainEvent::OrchestratorChanged` + test.\n- `crates/domain/src/lib.rs` — réexport de `OrchestratorDesignation`.\n\n### Signatures finales exactes (points d'accroche pour les tickets aval)\n\n**`fileguard.rs`** (réexportées depuis `domain::`) :\n```rust\npub struct OrchestratorDesignation(/* private Option */);\nimpl OrchestratorDesignation {\n pub const fn none() -> Self;\n pub const fn of(agent: AgentId) -> Self;\n pub const fn designated(&self) -> Option;\n}\n// dérive Debug, Clone, Copy, PartialEq, Eq\n\npub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool;\npub fn may_write_directly(\n who: ConversationParty,\n res: &GuardedResource,\n d: &OrchestratorDesignation,\n) -> bool;\n```\nSémantique : `User => true` (humain permanent), `Agent{id} => d.designated() == Some(id)`. `may_write_directly` ⇒ `is_orchestrator` si `res.is_project_context()`, sinon `true`. Les deux perdent `const` (le `match` sur `who` n'est pas const-compatible ici).\n\n**`agent.rs` — `AgentManifest`** (nouveau champ `pub orchestrator: Option` avec `#[serde(default, skip_serializing_if = \"Option::is_none\")]`) :\n```rust\n// constructeur historique inchangé (orchestrator = None, toujours valide)\npub fn new(version: u32, entries: Vec) -> Result;\n// constructeur validant la désignation explicite (intégrité référentielle)\npub fn with_orchestrator(\n version: u32,\n entries: Vec,\n orchestrator: Option,\n) -> Result;\n\npub fn effective_orchestrator(&self) -> Option; // designation || entries.first()\npub fn orchestrator_designation(&self) -> OrchestratorDesignation; // fold de l'effectif vers le VO\npub fn designate(&mut self, id: AgentId) -> Result<(), DomainError>; // radio, écrase, valide l'appartenance\npub fn on_agent_deleted(&mut self, removed: AgentId); // Some(removed) -> None (succession lazy)\n```\nChoix d'implémentation à noter : `AgentManifest::new` garde **sa signature** (orchestrator = None) ; la validation référentielle du constructeur demandée est portée par `with_orchestrator` (`new` y délègue avec `None`). Ça évite de casser tous les appelants `AgentManifest::new(version, entries)` existants. La validation : `orchestrator == Some(id)` ⇒ `id ∈ entries`, sinon `DomainError::InconsistentManifest`. Invariant d'ordre documenté sur le doccomment du struct : `entries` en ordre de création, `entries.first()` = plus ancien, pas de timestamp.\n\n**`events.rs`** :\n```rust\nDomainEvent::OrchestratorChanged {\n project_id: ProjectId,\n orchestrator: Option,\n}\n```\n\n### Tests couverts (tous verts)\n- single-writer préservé avec `none()` (agent refusé / humain autorisé) ;\n- agent désigné autorisé à écrire le contexte projet (+ autre agent refusé, humain toujours OK) — testé côté `fileguard` ET via `AgentManifest::orchestrator_designation()` ;\n- défaut « plus ancien » quand `orchestrator == None` (+ manifeste vide ⇒ `None`) ;\n- succession lazy : `on_agent_deleted` du désigné → `None` → retombe sur plus ancien ; no-op si l'agent supprimé n'était pas le désigné ;\n- `designate` écrase (radio) + rejette un agent absent (et préserve la désignation valide précédente) ;\n- validation référentielle du constructeur (`with_orchestrator`) ;\n- `OrchestratorChanged` construit/compare.\n\n### ⚠️ Impact aval à prévoir (hors scope T1, à câbler par les tickets suivants)\nLa couche **infrastructure ne compile plus tel quel** — c'est attendu, le câblage est aval :\n1. `crates/infrastructure/src/fileguard/mod.rs:93` appelle encore `may_write_directly(who, &res)` (2 args). L'adaptateur `RwFileGuard` doit recevoir/threader une `OrchestratorDesignation` (probablement injectée depuis le manifeste courant) et passer 3 args.\n2. Le nouveau champ `orchestrator` casse les **littéraux de struct exhaustifs** `AgentManifest { version, entries }` (≈ infra `store/context.rs`, application `context_guard.rs`/tests, `app-tauri/state.rs`, plusieurs tests). Fix mécanique : ajouter `orchestrator: None` ou `..Default::default()`. `Default` est dérivé donc `..Default::default()` marche.\n\n`cargo test -p domain` étant le périmètre demandé, le crate domaine est vert et autonome ; ces deux points sont la surface de câblage des tickets T2+."} +{"id":"96a4277d-ea90-4170-9eb1-52cf399408e3","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781736062933,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Suite de la feature orchestrateur, branche `feature/orchestrator-designation`. T1 (domaine) est vert. Implémente maintenant **T2 → T5** (infra + application backend) pour rétablir un workspace qui compile et tester. Suis le cadrage Architect, notamment l'**Alternative A** retenue.\n\n### T2 — Infra : guard = verrou pur + serde rétro-compatible (dépend T1)\n- **Alternative A (RETENUE)** : `RwFileGuard::acquire_write` (`crates/infrastructure/src/fileguard/mod.rs`) NE fait plus le check d'identité orchestrateur. Il redevient un **verrou pur** (sérialise les writers, comme un rwlock). Donc retire l'appel `may_write_directly` côté guard (la ligne ~93). L'autorisation single-writer remonte dans `ProposeContext` (T3). `GuardError::Forbidden` n'est plus émis par le guard — vérifie ce que ça implique pour le port/les tests du guard (déplace/retire le test « Forbidden » qui n'a plus lieu d'être à ce niveau, documente que le guard est désormais un lock pur). Si la signature du port `FileGuard::acquire_write` portait un paramètre lié à l'identité, garde-la cohérente.\n- **Serde** : corrige tous les littéraux exhaustifs `AgentManifest { version, entries }` cassés par le nouveau champ (ajoute `orchestrator: None` ou `..Default::default()`). Test round-trip : un `agents.json` legacy SANS le champ `orchestrator` se désérialise → `None` → `effective_orchestrator()` = plus ancien.\n\n### T3 — Application : autorisation propose (cœur MCP) (dépend T1, T2)\nDans `ProposeContext` (`crates/application/.../context_guard.rs`), branche globale (target = None) :\n```\nlet manifest = contexts.load_manifest(project).await?;\nlet d = manifest.orchestrator_designation();\nif may_write_directly(requester, &GuardedResource::ProjectContext, &d) {\n let _lease = guard.acquire_write(requester, ProjectContext).await?; // sérialise\n fs.write(project_context_file, content) -> Written\n} else {\n file_proposal(...) -> Proposed { path } // inchangé\n}\n```\nConséquence : quand l'appelant `idea_context_propose` (sans target) EST l'orchestrateur désigné, l'écriture devient DIRECTE ; sinon proposition ; l'humain écrit toujours direct. Tests : agent désigné → write direct ; agent non-désigné → proposition ; humain → direct.\n\n### T4 — Application : défaut + succession (dépend T1)\n- `DeleteAgent::execute` (`lifecycle.rs`) : après filtrage des entrées, appelle `manifest.on_agent_deleted(removed)` avant `save_manifest` ; émets `DomainEvent::OrchestratorChanged` si `effective_orchestrator()` a changé.\n- Défaut (Create scratch + template) : pas de logique à ajouter (modèle paresseux), mais ajoute les tests qui figent « 1er agent créé = orchestrateur effectif » et l'émission de `OrchestratorChanged` si l'effectif passe de None→1er.\n- Tests succession : désigné explicite supprimé → retombe sur plus ancien ; non-désigné supprimé → repointage paresseux.\n\n### T5 — Application : `SetOrchestrator` + ListAgents output (dépend T1)\n- Nouveau use case `SetOrchestrator { project, agent_id }` : `load_manifest` → `manifest.designate(agent_id)` (radio, écrase) → `save_manifest` → publie `OrchestratorChanged`. `NotFound`/erreur domaine si l'agent n'existe pas.\n- Étends `ListAgentsOutput` avec `orchestrator: Option` (= `effective_orchestrator()`).\n- Tests (store mock) : écrasement radio, agent inconnu → erreur, output porte l'effectif.\n\nRespecte l'archi hexagonale, SOLID, le style existant, aucun nouveau port (compose FileGuard / AgentContextStore / EventBus existants). Compile et teste les crates infra + application (`cargo test -p infrastructure -p application` ou vrais noms). Quand vert, réponds via `idea_reply` : fichiers touchés, signatures publiques finales de `SetOrchestrator` et du champ ajouté à `ListAgentsOutput` (accroche pour T6), et la sortie réelle des tests. Si rouge, donne l'erreur réelle."} +{"id":"d45770fd-efa2-40b6-8dae-2bd93716b496","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781938190834,"source":{"kind":"agent","agentId":"a6ced819-b893-4213-b003-9e9dc79b9641"},"role":"prompt","text":"Tâche ciblée sur la branche courante `feature/orchestrator-designation`.\n\nContexte:\n- Git a décidé de finir le chantier courant `orchestrator-designation` sur cette branche, sans switch/stash.\n- Main ne code pas; tu es chargé de la correction backend Rust.\n- Tests ciblés verts:\n - `cargo test -p infrastructure input --lib`: 35 passed.\n - `cargo test -p application --test orchestrator_service`: 45 passed.\n- Frontend vert:\n - `cd frontend && npx vitest run`: 41 files, 384 tests passed.\n - `cd frontend && npx tsc --noEmit`: OK.\n\nProblème à corriger:\n`cargo test --workspace` échoue à la compilation sur `crates/application/src/orchestrator/context_guard.rs`.\n\nSortie réelle:\n```text\nerror[E0061]: this function takes 3 arguments but 2 arguments were supplied\n --> crates/application/src/orchestrator/context_guard.rs:449:17\n |\n449 | if !may_write_directly(who, &res) {\n | ^^^^^^^^^^^^^^^^^^----------- argument #3 of type `&OrchestratorDesignation` is missing\n\nerror[E0560]: struct `ManifestEntry` has no field named `orchestrator`\n --> crates/application/src/orchestrator/context_guard.rs:616:21\n |\n616 | orchestrator: None,\n | ^^^^^^^^^^^^ `ManifestEntry` does not have this field\n\nerror[E0063]: missing field `orchestrator` in initializer of `AgentManifest`\n --> crates/application/src/orchestrator/context_guard.rs:613:23\n |\n613 | manifest: AgentManifest {\n | ^^^^^^^^^^^^^ missing `orchestrator`\n```\n\nAttendu:\n- Adapter `context_guard.rs` au nouveau modèle `AgentManifest { version, entries, orchestrator }` et à `may_write_directly(resource, designation)` sans contourner l'architecture.\n- Supprimer l'import inutilisé si nécessaire.\n- Garder le domaine propriétaire des règles de désignation orchestrateur.\n- Ne touche pas aux fichiers `.ideai/*` runtime.\n\nValidation minimale à exécuter après correction:\n1. `cargo test -p application --test orchestrator_service`\n2. `cargo test --workspace` si raisonnable.\n\nRéponds avec les fichiers modifiés et les commandes/test outputs réels. Si cette tâche arrive via IdeA, utilise impérativement `idea_reply`."} +{"id":"edf1ef36-9b9f-41f8-9fde-dfddf09366f9","conversation":"d5068bc8-786e-0428-1d14-83b93961a15e","atMs":1781938335787,"source":{"kind":"agent","agentId":"73c853d1-c0fd-463b-ad17-1d24fefa371f"},"role":"response","text":"Correction backend appliquée sur `crates/application/src/orchestrator/context_guard.rs`.\n\nModifications:\n- `ProposeContext` charge maintenant `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle la règle domaine `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)` avant toute écriture globale.\n- Le `FileGuard` reste un verrou de sérialisation: la décision orchestrateur/proposition reste dans le domaine via `may_write_directly`.\n- Le commentaire obsolète qui disait que le guard renvoyait `Forbidden` pour les non-orchestrateurs a été mis à jour.\n- Les tests locaux de `context_guard.rs` ont été adaptés au modèle `AgentManifest { version, entries, orchestrator }`.\n- Le fake `TestGuard` ne réimplémente plus l’autorisation; il reflète `infrastructure::RwFileGuard` comme verrou pur.\n\nFichier modifié par moi:\n- `crates/application/src/orchestrator/context_guard.rs`\n\nValidations exécutées:\n1. `cargo fmt --all && cargo test -p application --test orchestrator_service`\n - Résultat: OK\n - Sortie utile: `45 passed; 0 failed; 0 ignored`\n - Warning existant: `method writes is never used` dans `crates/application/tests/orchestrator_service.rs:1642`.\n\n2. `cargo test --workspace`\n - Résultat: compilation passée jusqu’à l’exécution des tests, puis échec dans `app-tauri` sur tests loopback Unix socket sous le sandbox courant.\n - Sortie utile: `app-tauri` lib: `39 passed; 8 failed`.\n - Échecs: `mcp_bridge::tests::end_to_end_over_real_loopback`, `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds`, et les tests `state::mcp_e2e_loopback_tests::*`.\n - Cause observée: `PermissionDenied` / `Operation not permitted` lors du bind ou de la pose de socket sous `/run/user/1000/idea-mcp/*.sock`, par exemple `bind test listener: Os { code: 1, kind: PermissionDenied, message: \"Operation not permitted\" }`.\n\n3. `cargo test -p application`\n - Résultat: OK\n - Sortie utile: tous les tests application sont verts, dont `orchestrator::context_guard`.\n - Extraits: lib `41 passed`; `orchestrator_service` `45 passed`; suite application complète terminée avec succès.\n\nNote: je n’ai pas touché aux fichiers `.ideai/*` runtime. Le `git status` montre beaucoup de fichiers déjà modifiés dans le workspace, dont `.ideai/*`; je les ai laissés tels quels."} diff --git a/.ideai/layouts.json b/.ideai/layouts.json index b8d6de8..81c0981 100644 --- a/.ideai/layouts.json +++ b/.ideai/layouts.json @@ -1,108 +1,37 @@ { "version": 1, - "activeId": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34", + "activeId": "c44601af-8100-4553-8bf2-e660dbc9309c", "layouts": [ { - "id": "dd38b8ed-7199-48a6-a63f-1d1462ca6a34", + "id": "c44601af-8100-4553-8bf2-e660dbc9309c", "name": "Default", "kind": "terminal", "tree": { "root": { "type": "split", "node": { - "id": "bd3dfce4-d31b-44d4-bd1e-b299b00d3cce", + "id": "ab32e1cf-0e12-4d52-b2fb-2e751d5ca96b", "direction": "row", "children": [ { "node": { - "type": "split", + "type": "leaf", "node": { - "id": "cd60658c-8c2d-4ef1-9149-073230350696", - "direction": "column", - "children": [ - { - "node": { - "type": "leaf", - "node": { - "id": "14d20dcb-f243-4381-a130-2d4d3d224f42", - "session": "adb24405-26ad-4920-a114-d27a21d9022a", - "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", - "agentWasRunning": true - } - }, - "weight": 1.1910967 - }, - { - "node": { - "type": "leaf", - "node": { - "id": "8d2c2720-b971-4792-8c0a-b28581eea8a8", - "session": "5def4f66-2921-4ddc-b9a2-be4958788081" - } - }, - "weight": 0.80890334 - } - ] + "id": "fb3a974b-8054-46b4-afb1-f7a0d8c54308", + "session": "f2854f1a-19e1-48ec-a945-ad8ec0ed3439", + "agent": "a6ced819-b893-4213-b003-9e9dc79b9641", + "agentWasRunning": true } }, "weight": 1.0 }, { "node": { - "type": "split", + "type": "leaf", "node": { - "id": "80fa1a60-60d7-4442-8614-fb055ea97e93", - "direction": "column", - "children": [ - { - "node": { - "type": "leaf", - "node": { - "id": "fbcfacb2-ad0b-46cc-9b53-8199d23f9324", - "session": "462f69bc-7957-437d-9096-e79b57af1223", - "agent": "73c853d1-c0fd-463b-ad17-1d24fefa371f", - "agentWasRunning": true - } - }, - "weight": 0.80238867 - }, - { - "node": { - "type": "split", - "node": { - "id": "8318f2b8-55e1-4fff-88ba-9891bd85c8a0", - "direction": "column", - "children": [ - { - "node": { - "type": "leaf", - "node": { - "id": "07698ab4-0700-4205-966b-a50b1e73ec2b", - "session": "f1780cb6-91c3-47d7-9081-1bcbdb96ba92", - "agent": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", - "agentWasRunning": true - } - }, - "weight": 1.0 - }, - { - "node": { - "type": "leaf", - "node": { - "id": "f31fa71f-c55e-4597-8ea0-caa9775531ff", - "session": "b04d11d9-0359-48a6-94ec-bd1d40eb6277", - "agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5", - "agentWasRunning": true - } - }, - "weight": 1.0 - } - ] - } - }, - "weight": 1.1976112 - } - ] + "id": "997ad931-ee93-4430-991d-e82996b2d87d", + "session": "2c245e2f-fdd5-43c5-b930-19ddf2003617", + "agent": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5" } }, "weight": 1.0 @@ -113,14 +42,14 @@ } }, { - "id": "a1958c6d-901f-4fbc-a621-d026aa6b2b62", + "id": "478acc7a-1afc-4cc0-9b1e-108eb83c5f5c", "name": "Git Graph", "kind": "gitGraph", "tree": { "root": { "type": "leaf", "node": { - "id": "7a088843-00e7-4ea0-9676-8c4acb1f6d1d" + "id": "c74eb1dc-0831-4f62-88dc-c08ec1854274" } } } diff --git a/.ideai/memory/MEMORY.md b/.ideai/memory/MEMORY.md index fb9b3d6..d9baa72 100644 --- a/.ideai/memory/MEMORY.md +++ b/.ideai/memory/MEMORY.md @@ -7,3 +7,7 @@ - [permissions-sandbox-system-state](permissions-sandbox-system-state.md) — Systeme de permissions/sandbox complet (Landlock sur PTY + structure) et le risque residuel $HOME/resume du chemin structure. - [session-limit-handling-design](session-limit-handling-design.md) — Design valide (detecteur hierarchique + reprise auto annulable) pour les limites de session des agents. - [git-owns-commit-merge-decisions](git-owns-commit-merge-decisions.md) — Ne jamais demander a l'utilisateur s'il faut committer/merger/brancher : l'agent Git tranche toute la topologie du depot. +- [conversation-rotation-safety-design](conversation-rotation-safety-design.md) — memory note conversation-rotation-safety-design +- [checkpoint-orchestrator-designation-restart](checkpoint-orchestrator-designation-restart.md) — memory note checkpoint-orchestrator-designation-restart +- [checkpoint-orchestrator-designation-backend-compile-fix](checkpoint-orchestrator-designation-backend-compile-fix.md) — memory note checkpoint-orchestrator-designation-backend-compile-fix +- [checkpoint-orchestrator-designation-qa-verdict](checkpoint-orchestrator-designation-qa-verdict.md) — memory note checkpoint-orchestrator-designation-qa-verdict diff --git a/.ideai/memory/checkpoint-orchestrator-designation-backend-compile-fix.md b/.ideai/memory/checkpoint-orchestrator-designation-backend-compile-fix.md new file mode 100644 index 0000000..5170b74 --- /dev/null +++ b/.ideai/memory/checkpoint-orchestrator-designation-backend-compile-fix.md @@ -0,0 +1,54 @@ +--- +name: checkpoint-orchestrator-designation-backend-compile-fix +description: memory note checkpoint-orchestrator-designation-backend-compile-fix +metadata: + type: project +--- +# Checkpoint — orchestrator-designation backend compile fix + +Date: 2026-06-20 + +## Branche + +`feature/orchestrator-designation`. + +## Décision Git + +Finir le chantier courant sur cette branche. Pas de stash, pas de switch. Après tests verts ou résidu qualifié: retour Git pour commits atomiques puis merge local vers `develop`. + +## Incident live + +La délégation vers Architect a été affichée dans son chat mais non soumise. Architect a été stoppé. Symptôme cohérent avec l'AppImage live qui n'intègre pas encore les correctifs WIP write-portal/headless delivery. + +## Vérifications Main avant correction DevBackend + +Verts: +- `cargo test -p infrastructure input --lib`: 35 passed. +- `cargo test -p application --test orchestrator_service`: 45 passed. +- `cd frontend && npx vitest run`: 41 files, 384 tests passed. +- `cd frontend && npx tsc --noEmit`: OK. + +Rouge initial: +- `cargo test --workspace` ne compilait pas `crates/application/src/orchestrator/context_guard.rs`: + - `may_write_directly` appelé avec 2 args au lieu de 3 (`OrchestratorDesignation` manquant). + - mauvais champ `orchestrator` mis sur `ManifestEntry` au lieu de `AgentManifest`. + - init `AgentManifest` sans `orchestrator`. + +## Correction DevBackend + +DevBackend a modifié `crates/application/src/orchestrator/context_guard.rs`: +- `ProposeContext` charge `AgentManifest`, récupère `manifest.orchestrator_designation()`, puis appelle `may_write_directly(requester, &GuardedResource::ProjectContext, &designation)`. +- Le `FileGuard` reste un verrou, pas le propriétaire de l'autorisation. +- Tests locaux adaptés à `AgentManifest { version, entries, orchestrator }`. + +Validations DevBackend: +- `cargo fmt --all && cargo test -p application --test orchestrator_service`: OK, 45 passed. +- `cargo test -p application`: OK. +- `cargo test --workspace`: compile maintenant plus loin mais échoue dans `app-tauri` sur tests loopback Unix socket sous sandbox (`PermissionDenied`/`Operation not permitted` lors du bind `/run/user/1000/idea-mcp/*.sock`). + +## Prochaine étape + +QA doit qualifier le résidu app-tauri: +- confirmer que les suites métier/orchestration sont vertes, +- confirmer si les échecs app-tauri sont environnement/sandbox et non régression, +- proposer la commande de validation acceptable ou le besoin de correction test/env. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-orchestrator-designation-qa-verdict.md b/.ideai/memory/checkpoint-orchestrator-designation-qa-verdict.md new file mode 100644 index 0000000..b087cc4 --- /dev/null +++ b/.ideai/memory/checkpoint-orchestrator-designation-qa-verdict.md @@ -0,0 +1,42 @@ +--- +name: checkpoint-orchestrator-designation-qa-verdict +description: memory note checkpoint-orchestrator-designation-qa-verdict +metadata: + type: project +--- +# Checkpoint — QA verdict orchestrator-designation + +Date: 2026-06-20 + +## Branche + +`feature/orchestrator-designation`. + +## Verdict QA + +Chantier validable sous contrainte d'environnement. Pas de régression fonctionnelle liée à `context_guard.rs`. + +## Commandes vertes qui font foi + +- `cargo fmt --all -- --check`: OK. +- `cargo test -p application --test orchestrator_service`: OK, 45 passed. +- `cargo test -p application`: OK, suite application complète verte, incluant les tests context guard orchestrateur/proposition. +- `cargo test -p infrastructure input --lib`: OK, 35 passed. +- `cd frontend && npx vitest run`: OK, 41 files / 384 tests passed. +- `cd frontend && npx tsc --noEmit`: OK. + +## Résidu app-tauri + +- `cargo test -p app-tauri --lib`: rouge, 39 passed / 8 failed. +- `cargo test --workspace`: rouge sur le même bloc app-tauri. + +Échecs exacts: tests loopback Unix socket: +- `mcp_bridge::tests::end_to_end_over_real_loopback` +- `state::bind_endpoint_d1_tests::rebind_after_corpse_socket_succeeds` +- `state::mcp_e2e_loopback_tests::*` + +Cause qualifiée par QA: contrainte environnement/sandbox. Une sonde Node minimale échoue aussi à `listen()` sur Unix socket avec `EPERM` sous `/run/user/1000` et sous `/tmp`. Donc les tests qui exigent un vrai socket Unix ne sont pas exécutables dans ce sandbox. + +## Prochaine étape + +Retour à Git pour commits atomiques sur `feature/orchestrator-designation`, puis décision de merge local vers `develop` selon sa stratégie. Aucun push. \ No newline at end of file diff --git a/.ideai/memory/checkpoint-orchestrator-designation-restart.md b/.ideai/memory/checkpoint-orchestrator-designation-restart.md new file mode 100644 index 0000000..f8ff58d --- /dev/null +++ b/.ideai/memory/checkpoint-orchestrator-designation-restart.md @@ -0,0 +1,38 @@ +--- +name: checkpoint-orchestrator-designation-restart +description: memory note checkpoint-orchestrator-designation-restart +metadata: + type: project +--- +# Checkpoint — reprise chantier orchestrator-designation + +Date: 2026-06-20 + +## Situation + +L'utilisateur a demandé de terminer les chantiers ouverts. Main a repris le cycle proprement. + +## Décision Git obtenue + +Git a inspecté l'état local et décidé: + +- Branche courante: `feature/orchestrator-designation`. +- Worktree dirty important mais analysé comme mono-thème: chantier orchestrateur/designation + diagnostic inter-agents. +- Ne pas stash, ne pas switch, ne pas ouvrir une nouvelle branche maintenant. +- Premier chantier à fermer: `orchestrator-designation` sur la branche actuelle. +- Après stabilisation + tests verts: retour à Git pour commits atomiques puis merge local vers `develop`. +- Ensuite seulement ouvrir des branches dédiées pour les autres chantiers. + +## Incident de délégation + +Main a tenté de demander à Architect de cadrer `orchestrator-designation` via `idea_ask_agent`. +L'utilisateur a interrompu et signalé qu'Architect était bloqué avec le texte de la tâche affiché dans son chat mais non envoyé. + +Interprétation: symptôme cohérent avec les problèmes actuels de write portal / soumission physique PTY / délégation visible-background. Ne pas empiler de nouvelles délégations tant que cette zone n'est pas stabilisée. + +## Prochaine reprise recommandée + +1. Nettoyer/arrêter l'agent Architect bloqué si nécessaire. +2. Reprendre le chantier courant localement en lecture seule pour identifier exactement les changements WIP liés à la soumission de délégations. +3. Comme Main ne code pas, utiliser la délégation seulement vers un agent dont la soumission est confirmée fonctionnelle, ou lancer les agents en visible et vérifier qu'ils reçoivent réellement la tâche. +4. Priorité technique immédiate: fermer `orchestrator-designation`, car la délégation inter-agents fiable conditionne tous les autres chantiers. \ No newline at end of file diff --git a/.ideai/memory/conversation-rotation-safety-design.md b/.ideai/memory/conversation-rotation-safety-design.md new file mode 100644 index 0000000..a033d09 --- /dev/null +++ b/.ideai/memory/conversation-rotation-safety-design.md @@ -0,0 +1,49 @@ +--- +name: conversation-rotation-safety-design +description: memory note conversation-rotation-safety-design +metadata: + type: project +--- +--- +name: conversation-rotation-safety-design +description: Cadrage initial pour la compression/rotation automatique non interruptive des conversations d'agents. +metadata: + type: project +--- + +# Conversation Rotation Safety Design + +Objectif : reduire le cout token des conversations longues sans degrader la stabilite des agents ni les contacts inter-agents. + +Socle existant a reutiliser : +- log canonique `.ideai/conversations//log.jsonl` ; +- `handoff.md` incremental ; +- injection du handoff au lancement/relaunch ; +- `LeafCell.conversation_id` = id logique IdeA de paire, distinct du resumable provider ; +- `providers.json` pour `(pair_conversation_id, provider_key) -> engine_session_id` ; +- `InputMediator.busy_state(agent)` et FIFO par agent ; +- `ask_agent` avec lock de tour par agent + wait-for graph anti-deadlock ; +- attach/detach cellule deja modelise : la cellule est une vue, `TerminalSessions::rebind_agent_node` existe ; +- frontend write-portal sait deja compter la saisie humaine et declarer `set_front_attached`. + +Regle produit centrale : rotation automatique opportuniste, jamais interruptive. IdeA ne compacte/rote une session qu'a un point sur. + +Conditions minimales avant rotation : +- agent `Idle` (`InputMediator.busy_state == Idle`) ; +- aucune delegation/ticket/reply en cours ; +- aucun contact entrant en cours, sinon queue ou ancienne session jusqu'a bascule ; +- checkpoint durable OK : prompt/response appendes au log + `handoff.md` a jour ; +- cellule stable : pas d'attach/detach ou changement de layout en cours ; +- utilisateur non en train d'ecrire : focus/buffer non vide/frappe recente/IME composition/prompt interactif detectable ; +- nouvelle session demarree detached/background et prete avant bascule ; +- bascule atomique routing logique + attachement cellule ; ancienne session retiree ensuite. + +Etat cible conceptuel : +`RotationRequested -> WaitForAgentIdle -> WaitForNoDelegation -> WaitForNoIncomingRoute -> WaitForCellStable -> WaitForUserInputClear -> Checkpointing -> StartReplacementDetached -> AttachReplacementToCell -> SwitchRouting -> RetireOldSession`. + +Premier lot recommande : fondation non destructive. +- Ajouter modeles/policy de `ConversationRotation` et raisons de blocage. +- Ajouter query/use case d'evaluation qui retourne `Ready` ou `Blocked(reasons)` sans tuer ni relancer. +- Exposer les signaux manquants de cellule/saisie depuis le frontend vers backend, d'abord comme etat consultable. +- Publier des events de statut de rotation uniquement informatifs. +- Ne pas implementer la bascule physique avant que les tests de surete soient verts. \ No newline at end of file diff --git a/.ideai/permissions.json b/.ideai/permissions.json index d631d6a..feafdd6 100644 --- a/.ideai/permissions.json +++ b/.ideai/permissions.json @@ -34,5 +34,121 @@ } ], "fallback": "allow" - } + }, + "agents": [ + { + "agentId": "a6ced819-b893-4213-b003-9e9dc79b9641", + "permissions": { + "rules": [ + { + "capability": "read", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "write", + "effect": "deny", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "delete", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "executeBash", + "effect": "allow", + "paths": [], + "commands": [] + } + ], + "fallback": "allow" + } + }, + { + "agentId": "dce19c75-9669-4e45-b8de-9950025157da", + "permissions": { + "rules": [ + { + "capability": "read", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "write", + "effect": "deny", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "delete", + "effect": "deny", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "executeBash", + "effect": "allow", + "paths": [], + "commands": [] + } + ], + "fallback": "allow" + } + }, + { + "agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5", + "permissions": { + "rules": [ + { + "capability": "read", + "effect": "allow", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "write", + "effect": "deny", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "delete", + "effect": "deny", + "paths": [ + "**" + ], + "commands": [] + }, + { + "capability": "executeBash", + "effect": "allow", + "paths": [], + "commands": [] + } + ], + "fallback": "allow" + } + } + ] }