diff --git a/.ideai/memory/ticket101-cross-talk-multi-project-rootcause.md b/.ideai/memory/ticket101-cross-talk-multi-project-rootcause.md new file mode 100644 index 0000000..e33a95a --- /dev/null +++ b/.ideai/memory/ticket101-cross-talk-multi-project-rootcause.md @@ -0,0 +1,67 @@ +--- +name: ticket101-cross-talk-multi-project-rootcause +description: memory note ticket101-cross-talk-multi-project-rootcause +metadata: + type: project +--- +--- +slug: ticket101-cross-talk-multi-project-rootcause +title: "Ticket #101 — défaut d'isolation multi-projet (registres runtime globaux par AgentId)" +type: reference +description: Cause racine du cross-talk entre projets ouverts simultanément (branche hybride wear-os + notification de tâche backend perdue) : les stores disque IdeA sont scoppés par projet, mais plusieurs registres mémoire runtime critiques restent indexés par AgentId seul, sans re-mint des UUID à l'ouverture. Plan de correction figé en 6 lots (clé RuntimeAgentKey). +--- + +# Ticket #101 — défaut d'isolation multi-projet (cause racine) + +## Symptômes (2 manifestations, même défaut) +- **A — contamination du contexte d'inférence** : un agent a créé une pseudo-branche `feature/ticket91-wear-os-watch-sync` (ticket #91 purement front) où le suffixe `wear-os-watch-sync` appartient à l'AUTRE projet de l'utilisateur. Le nom n'apparaît dans aucun fichier `.ideai/` du projet IdeA → contamination au niveau du contexte d'inférence runtime (conversation injectée à l'agent Git mélangeait les projets). Preuve topo préservée : `main` divergé de `develop` (da907b8 vs 6a87c46), `feature/ticket99-agent-model-configuration` créée depuis main au lieu de develop. +- **B — notification de tâche backend perdue / agent qui s'arrête** (sujet original #101) : un agent OpenCode lance `idea_run_in_background` puis s'arrête, sans retour de notification garanti. + +## Cause racine (Architect, 2026-07-25) +Stores disque **correctement scoppés par projet** (mémoire, contexte, handoff, live-state lus depuis `input.project.root` ; agents persistés dans `.ideai/agents.json` par root projet). MAIS les agents **ne re-mint pas leurs UUID à l'ouverture** → deux projets (surtout si l'un est une copie) peuvent porter les **mêmes `AgentId`**. + +Plusieurs **registres mémoire runtime restent globaux, indexés par `AgentId` seul** : +- ConversationRegistry global + `ConversationId` dérivé des UUID agents seuls — `crates/domain/src/conversation.rs:71`, `crates/infrastructure/src/conversation/mod.rs:41`, orchestrateur `crates/application/src/orchestrator/service.rs:2192`. +- Sessions PTY/structured retrouvées par `agent_id` seul — `crates/application/src/terminal/registry.rs:182,437` ; réutilisation session `service.rs:2293,2368`. +- Verrous ask, busy-state, liveness, délégations différées par `AgentId` seul — `service.rs:418`, `crates/infrastructure/src/input/mod.rs:47`. +- Inbox/mailbox par `AgentId` seul — `crates/domain/src/inbox.rs:68,156`, `input/mod.rs:1052`, `crates/infrastructure/src/mailbox/mod.rs:44`. +- Wake par `AgentId` seul — `crates/application/src/orchestrator/wake.rs:87`, provider `crates/backend/src/lib.rs:687`. + +→ Un agent du projet courant peut se rattacher à la conversation/session/inbox d'un autre projet (collision d'UUID) : contamination d'inférence (A) ET complétion livrable/bloquée/réveillant la mauvaise session (B). + +## Pour B : runtime vs modèle +La chaîne sink→`project_id`→wake est correcte jusqu'au bridge (`crates/infrastructure/src/background_task/sink.rs:23,142` ; `crates/backend/src/lib.rs:2228` recharge le projet par `project_id` puis `wake_agent`). Le défaut réapparaît juste après (inbox/mailbox/busy/session par AgentId seul). **Runtime IdeA suspecté en premier.** Le modèle GLM 5.2 n'est l'hypothèse principale que si les logs prouvent (pour le bon `project_id`) : enqueue + wake + `BackgroundTaskCompletionDelivered` émis sans reprise utile. Traces : `lib.rs:2238`, `wake.rs:93`. + +## Plan de correction (6 lots, figé) +1. Introduire `RuntimeAgentKey { project_id, agent_id }` ; interdire toute map app-wide indexée par `AgentId` seul. +2. Propager la clé dans `AgentInbox`, `InputMediator`, `AgentMailbox`, busy/liveness/deferred, wake, session lookup, ask-locks. **Correctif structurel commun à A et B.** +3. Qualifier les conversations par projet : `ConversationId` + `ConversationRegistry` intègrent `ProjectId`. +4. Requalifier `TerminalSessions`/`StructuredSessions` par `(project_id, agent_id)` ; `AppWakeSessionProvider` refuse toute session vivante d'un autre projet. +5. Audit des autres états mémoire similaires (`session_limit`, tables de reprise/verrous par agent) pour éviter une demi-correction. +6. Télémétrie `project_id` explicite sur les logs diag critiques de wake/routage. + +## Invariants +- Sources de contexte sur disque restent root-scopées (ne pas régresser). +- Templates/profils globaux restent globaux produit — ne doivent pas devenir vecteurs de session/conversation partagée. +- Un projet non-actif doit continuer à recevoir ses wakes et complétions. +- Pas de régression OpenCode/Codex/Claude (correctif transverse runtime, pas moteur-spécifique). +- Aucun nouveau DTO frontend pour la correction minimale. + +## QA — critère de vérité +Test d'intégration **non-cross-talk** : 2 projets ouverts simultanément contenant **volontairement les mêmes `AgentId`** : +- délégation dans projet A ne réutilise jamais une session/conversation vivante de projet B ; +- complétion `(project A, agent X)` n'entre ni dans l'inbox ni dans la session de `(project B, agent X)` ; +- le wake d'un projet non-actif fonctionne quand même ; +- une collision d'ids qui échouait avant devient verte sur PTY, structured et background wake. + +## Hors périmètre +- #91 (popup de notification) : purement front, indépendant du défaut runtime. +- Remint systématique des AgentId à l'ouverture : piste complémentaire non retenue dans le fix minimal (la clé runtime scellée par projet rend la collision inoffensive même sans remint). + +## Topologie +- Branche de travail à créer pour le fix (Git décidera). Base `develop` (6a87c46). `main`/`feature/ticket99-agent-model-configuration` divergent sur da907b8 (preuves préservées, à nettoyer après enquête). + +## Lié +- #91 (relatesTo) : popup de notification — front pur. +- Mémoire `background-tasks-first-class-design` + `b8-command-runner-pty-framing` : design du flux de complétion (le défaut est en aval du sink). +- Mémoire `mcp-bridge-and-delegation-runtime-notes` : règle rebuild AppImage (le binaire qui tourne = AppImage, pas les sources). \ No newline at end of file diff --git a/.ideai/tickets/101/carnet.md b/.ideai/tickets/101/carnet.md new file mode 100644 index 0000000..4886ee1 --- /dev/null +++ b/.ideai/tickets/101/carnet.md @@ -0,0 +1,90 @@ +--- +issueRef: "#101" +version: 5 +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +updatedAt: 1785001809027 +--- +# Carnet #101 — défaut d'isolation multi-projet (cause racine figée) + +> Diagnostic Architect (2026-07-25). Source de vérité du fix. Mémoire durable : +> `ticket101-cross-talk-multi-project-rootcause`. + +## Cause racine +Stores disque IdeA **correctement scoppés par projet** (mémoire, contexte, handoff, live-state lus +depuis `input.project.root` ; agents persistés dans `.ideai/agents.json` par root projet, **sans +re-mint des UUID à l'ouverture**). Mais plusieurs **registres mémoire runtime restent globaux, +indexés par `AgentId` seul** → deux projets ouverts (surtout si l'un est une copie) peuvent porter +les **mêmes `AgentId`** et le runtime les confond. + +### 2 manifestations, même défaut +- **A — contamination du contexte d'inférence** : conversation/session d'un projet réutilisée pour + l'autre → contexte injecté à l'agent contaminé (preuve : pseudo-branche `wear-os-watch-sync` + créée par l'agent Git avec un nom de l'autre projet). +- **B — notification de tâche backend perdue / agent s'arrête** (sujet original) : complétion + livrable/bloquée/réveillant la mauvaise session. + +## Points précis (registres par `AgentId` seul à requalifier) +- ConversationRegistry global + `ConversationId` dérivé des UUID agents seuls : + `crates/domain/src/conversation.rs:71`, `crates/infrastructure/src/conversation/mod.rs:41`, + orchestrateur `crates/application/src/orchestrator/service.rs:2192` (et `:2293`, `:2368`). +- Sessions PTY/structured par `agent_id` seul : + `crates/application/src/terminal/registry.rs:182,437`. +- Verrous ask / busy-state / liveness / délégations différées : + `crates/application/src/orchestrator/service.rs:418`, + `crates/infrastructure/src/input/mod.rs:47`. +- Inbox/mailbox par `AgentId` seul : `crates/domain/src/inbox.rs:68,156`, + `crates/infrastructure/src/input/mod.rs:1052`, `crates/infrastructure/src/mailbox/mod.rs:44`. +- Wake par `AgentId` seul : `crates/application/src/orchestrator/wake.rs:87`, + provider `crates/backend/src/lib.rs:687`. + +### Pour B — distinction runtime vs modèle +La chaîne sink→`project_id`→wake est **correcte** jusqu'au bridge +(`crates/infrastructure/src/background_task/sink.rs:23,142` ; `crates/backend/src/lib.rs:2228` +recharge le projet par `project_id` puis `wake_agent`). Le défaut réapparaît juste après +(inbox/mailbox/busy/session par AgentId seul). **Runtime IdeA suspecté en premier.** GLM 5.2 n'est +l'hypothèse principale que si les logs prouvent enqueue+wake+`BackgroundTaskCompletionDelivered` +sur le bon projet sans reprise. Traces : `lib.rs:2238`, `wake.rs:93`. + +## Décision utilisateur +**Lancer le fix structurel maintenant (lots 1-2).** La collision d'UUID et les logs B se +vérifieront en parallèle. + +## Périmètre DevBackend (lots 1-2 de ce fix) +- **Lot 1** : introduire une clé runtime scellée `RuntimeAgentKey { project_id, agent_id }` ; + interdire toute map app-wide indexée par `AgentId` seul. +- **Lot 2** : propager la clé dans `AgentInbox`, `InputMediator`, `AgentMailbox`, + busy/liveness/deferred, wake, session lookup, ask-locks. **Correctif structurel commun à A et B.** + +Lots suivants (3-6, hors premier passage) : qualifier ConversationId/registry par projet (lot 3) ; +requalifier `TerminalSessions`/`StructuredSessions` par `(project_id, agent_id)` + +`AppWakeSessionProvider` refuse l'autre projet (lot 4) ; audit `session_limit`/tables reprise (lot 5) ; +télémétrie `project_id` sur logs diag wake/routage (lot 6). + +## Invariants +- Sources de contexte sur disque restent root-scopées (ne pas régresser). +- Templates/profils globaux restent globaux produit (pas de vecteur de session/conversation partagée). +- Un projet non-actif doit continuer à recevoir ses wakes et complétions. +- Pas de régression OpenCode/Codex/Claude (correctif transverse runtime, pas moteur-spécifique). +- Aucun nouveau DTO frontend pour la correction minimale. + +## QA — critère de vérité +Test d'intégration **non-cross-talk** : 2 projets ouverts avec **volontairement les mêmes `AgentId`** : +- délégation projet A ne réutilise jamais session/conversation vivante de projet B ; +- complétion `(projet A, agent X)` n'entre ni dans l'inbox ni dans la session de `(projet B, agent X)` ; +- wake d'un projet non-actif fonctionne ; +- collision d'ids qui échouait avant → verte sur PTY, structured et background wake. + +## Topologie +- Branche de travail : `feature/ticket101-multi-project-isolation` (créée par Git depuis + `develop` 6a87c46). +- `main` (da907b8), `feature/ticket99-agent-model-configuration` (da907b8), + `fix/terminal-resize-bug` (da907b8) : **preuves préservées**, à nettoyer après enquête. + +## Hors périmètre +- #91 (popup de notification) : purement front, indépendant. +- Remint systématique des AgentId à l'ouverture : non retenu (la clé runtime rend la collision + inoffensive même sans remint). + +## Lié +- #91 (relatesTo). Mémoires : `background-tasks-first-class-design`, `b8-command-runner-pty-framing`, + `mcp-bridge-and-delegation-runtime-notes` (règle rebuild AppImage). \ No newline at end of file diff --git a/.ideai/tickets/101/issue.md b/.ideai/tickets/101/issue.md new file mode 100644 index 0000000..1853c6e --- /dev/null +++ b/.ideai/tickets/101/issue.md @@ -0,0 +1,16 @@ +--- +id: "05f9f05b-97d4-4ae2-9fd9-220dd71f7231" +number: 101 +title: "[Bug] Soucis de retour de notification sur les taches backend" +status: "open" +priority: "critical" +sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2" +links: [] +agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}] +createdBy: {"kind":"user"} +updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"} +createdAt: 1784993230013 +updatedAt: 1785001809027 +version: 5 +--- +Lorsque un agent Opencode lance une tache backend, il s'arrete de travailler et je ne suis pas sur q'uil y ai un jour un retour de notification. Je ne sais aps si le soucis provient de OpenCode ou s'il provient du model (GLM 5.2 ici) \ No newline at end of file diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index c87d9e0..0923798 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -52,13 +52,12 @@ use crate::dto::{ MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, - ProjectMcpToolPermissionsDto, - ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, - ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, - RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, - ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, - SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, - SetActiveLayoutRequestDto, + ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto, + ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto, + ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk, + ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto, + SaveEmbedderProfileRequestDto, SaveModelServerRequestDto, + SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, @@ -1547,10 +1546,9 @@ pub async fn read_conversation_page( /// `Arc` registries already held by [`AppState`]; the aggregation logic itself /// is not duplicated. /// -/// `project_id` is accepted for API symmetry and future per-project scoping; the -/// session registry is process-wide today, so the full live set is returned (a -/// project's agent ids are disjoint from other projects' by construction, so the -/// frontend can filter by the agents it knows). +/// `project_id` scopes the process-wide runtime registry before serialization so two +/// open projects carrying the same persisted `AgentId` cannot see each other's live +/// session. /// /// # Errors /// Returns an [`ErrorDto`] (`INVALID` for a malformed project id). @@ -1561,13 +1559,16 @@ pub fn list_live_agents( ) -> Result { // Validate the id shape for a consistent contract, even though the registry // is not project-scoped yet. - let _ = parse_project_id(&project_id)?; + let project_id = parse_project_id(&project_id)?; let live = LiveSessions::new( std::sync::Arc::clone(&state.terminal_sessions), std::sync::Arc::clone(&state.structured_sessions), ); Ok(LiveAgentListDto::from_snapshots( - live.live_agent_snapshots(), + live.live_agent_snapshots() + .into_iter() + .filter(|snapshot| snapshot.project_id == project_id) + .collect(), )) } @@ -1620,9 +1621,9 @@ pub async fn stop_live_agent( .active_wait_dependencies(agent_id); // Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est // droppé ⇒ polling stoppé) avant de démonter sa session. - state.stop_turn_watch(agent_id); + state.stop_turn_watch(project.id, agent_id); for dependency in dependencies { - state.stop_turn_watch(dependency); + state.stop_turn_watch(project.id, dependency); } state .stop_live_agent @@ -1745,6 +1746,7 @@ pub async fn launch_agent( state: State<'_, AppState>, ) -> Result { let project = resolve_project(&request.project_id, &state).await?; + let project_id = project.id; let agent_id = parse_agent_id(&request.agent_id)?; // The hosting cell drives the singleton-invariant guard. Parse it when the // frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an @@ -1804,7 +1806,7 @@ pub async fn launch_agent( if let Ok(mut contexts) = state.resume_contexts.lock() { contexts.insert( - agent_id, + domain::RuntimeAgentKey::new(project_id, agent_id), crate::state::ResumeContext { project: resume_project, rows: request.rows, @@ -1819,6 +1821,7 @@ pub async fn launch_agent( // posé au 1er lancement persiste. cwd = run dir isolé de l'agent. if let Some(profile) = output.profile.as_ref() { state.arm_turn_watch( + project_id, &watch_root, agent_id, profile, @@ -1900,6 +1903,7 @@ pub async fn launch_agent( .detect(&text, domain::ports::Clock::now_millis(&*detect_clock)) { service.on_rate_limited( + project_id, agent_id, host_node_id, conversation_id.clone(), @@ -2024,7 +2028,7 @@ pub async fn agent_send( // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on // continue à drainer comme pour un battement. if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event { - if let Some((agent_id, node_id, conversation_id)) = &meta { + if let Some((project_id, agent_id, node_id, conversation_id)) = &meta { let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) { (Some(conversation_id), resets_at_ms) => { (Some(conversation_id.clone()), *resets_at_ms) @@ -2034,7 +2038,13 @@ pub async fn agent_send( // une reprise non reprenable ; surfacer le fallback humain. (None, Some(_)) => (None, None), }; - service.on_rate_limited(*agent_id, *node_id, conversation_id, resets_at_ms); + service.on_rate_limited( + *project_id, + *agent_id, + *node_id, + conversation_id, + resets_at_ms, + ); } } // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire @@ -2110,28 +2120,47 @@ pub async fn set_resume_at( ) -> Result<(), ErrorDto> { let id = parse_agent_id(&agent_id)?; - // Résolution agent→cellule : la registry des sessions vivantes est la source de - // vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`), - // puis le terminal (PTY). - let node_id = state - .structured_sessions - .node_for_agent(&id) - .or_else(|| state.terminal_sessions.node_for_agent(&id)) - .ok_or_else(|| { - ErrorDto::from(AppError::NotFound(format!( + // Résolution agent→(projet, cellule) : la registry des sessions vivantes est la + // source de vérité. Sans `project_id` dans le DTO historique, on exige un match + // unique pour éviter d'armer la reprise d'un homonyme dans le mauvais projet. + let live = LiveSessions::new( + std::sync::Arc::clone(&state.terminal_sessions), + std::sync::Arc::clone(&state.structured_sessions), + ); + let matches = live + .live_agent_snapshots() + .into_iter() + .filter(|snapshot| snapshot.agent_id == id) + .collect::>(); + let snapshot = match matches.as_slice() { + [snapshot] => snapshot, + [] => { + return Err(ErrorDto::from(AppError::NotFound(format!( "aucune cellule vivante pour l'agent {id}" - ))) - })?; + )))); + } + _ => { + return Err(ErrorDto::from(AppError::Invalid(format!( + "plusieurs projets vivants portent l'agent {id}" + )))); + } + }; + let project_id = snapshot.project_id; + let node_id = snapshot.node_id; // `conversation_id` best-effort : seule une session structurée vivante l'expose. let conversation_id = state .structured_sessions - .session_for_agent(&id) + .session_for_agent_in_project(project_id, &id) .and_then(|s| s.conversation_id()); - state - .session_limit_service - .confirm_human_resume(id, node_id, conversation_id, resets_at_ms); + state.session_limit_service.confirm_human_resume( + project_id, + id, + node_id, + conversation_id, + resets_at_ms, + ); Ok(()) } @@ -2210,9 +2239,39 @@ pub async fn set_front_attached( "[delivery] set_front_attached command: agent={agent_id} attached={}", request.attached ); - state - .orchestrator_service - .set_agent_front_attached(agent_id, request.attached); + let mut matches = Vec::new(); + for project in state + .project_store + .list_projects() + .await + .map_err(|err| ErrorDto::from(application::AppError::Store(err.to_string())))? + { + if state + .list_agents + .execute(application::ListAgentsInput { + project: project.clone(), + }) + .await + .map(|out| out.agents.iter().any(|agent| agent.id == agent_id)) + .unwrap_or(false) + { + matches.push(project); + } + } + match matches.as_slice() { + [project] => { + state + .orchestrator_service + .set_agent_front_attached(project, agent_id, request.attached) + } + [] => application::diag!( + "[delivery] set_front_attached ignored: agent={agent_id} not found in known projects" + ), + _ => application::diag!( + "[delivery] set_front_attached ignored: ambiguous agent={agent_id} across {} projects", + matches.len() + ), + } Ok(()) } diff --git a/crates/app-tauri/tests/dto_agents.rs b/crates/app-tauri/tests/dto_agents.rs index e5c59a5..98948ba 100644 --- a/crates/app-tauri/tests/dto_agents.rs +++ b/crates/app-tauri/tests/dto_agents.rs @@ -188,6 +188,7 @@ fn live_agent_list_dto_serialises_camelcase_array() { let node_a = NodeId::from_uuid(Uuid::from_u128(21)); let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31)); let dto = LiveAgentListDto::from_snapshots(vec![LiveSessionSnapshot { + project_id: domain::ProjectId::from_uuid(Uuid::from_u128(1)), agent_id: agent_a, node_id: node_a, session_id: session_a, diff --git a/crates/app-tauri/tests/session_limit_wiring.rs b/crates/app-tauri/tests/session_limit_wiring.rs index 5f5a249..7a8b7cf 100644 --- a/crates/app-tauri/tests/session_limit_wiring.rs +++ b/crates/app-tauri/tests/session_limit_wiring.rs @@ -17,7 +17,7 @@ use std::time::Duration; use app_tauri_lib::state::AppState; use async_trait::async_trait; use domain::events::DomainEvent; -use domain::ids::{NodeId, SessionId}; +use domain::ids::{NodeId, ProjectId, SessionId}; use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream}; use domain::AgentId; use infrastructure::UuidGenerator; @@ -35,6 +35,10 @@ fn node(n: u128) -> NodeId { NodeId::from_uuid(uuid::Uuid::from_u128(n)) } +fn project_id(n: u128) -> ProjectId { + ProjectId::from_uuid(uuid::Uuid::from_u128(n)) +} + fn session(n: u128) -> SessionId { SessionId::from_uuid(uuid::Uuid::from_u128(n)) } @@ -85,9 +89,13 @@ async fn on_rate_limited_arms_a_cancellable_resume_over_the_real_bus() { // Reset far in the future so the scheduler does NOT fire before we cancel. let resets_at_ms = i64::MAX; - state - .session_limit_service - .on_rate_limited(agent(7), node(70), None, Some(resets_at_ms)); + state.session_limit_service.on_rate_limited( + project_id(1), + agent(7), + node(70), + None, + Some(resets_at_ms), + ); // The two arming events, in order, on the bus. let mut saw_rate_limited = false; @@ -145,7 +153,7 @@ async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() { node(70), ); - let (agent_id, node_id, conversation_id) = state + let (project_id, agent_id, node_id, conversation_id) = state .structured_sessions .meta_for_session(&sid) .expect("live structured session metadata"); @@ -155,6 +163,7 @@ async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() { let resets_at_ms = i64::MAX; state.session_limit_service.on_rate_limited( + project_id, agent_id, node_id, conversation_id, @@ -205,7 +214,7 @@ async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() node(70), ); - let (agent_id, node_id, conversation_id) = state + let (project_id, agent_id, node_id, conversation_id) = state .structured_sessions .meta_for_session(&sid) .expect("live structured session metadata"); @@ -217,9 +226,13 @@ async fn direct_structured_rate_limit_without_conversation_uses_human_fallback() (None, None) => (None, None), (None, Some(_)) => (None, None), }; - state - .session_limit_service - .on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms); + state.session_limit_service.on_rate_limited( + project_id, + agent_id, + node_id, + conversation_id, + resets_at_ms, + ); let mut saw_rate_limited_none = false; let mut saw_suspected = false; @@ -298,9 +311,13 @@ async fn confirm_human_resume_arms_a_cancellable_resume_over_the_real_bus() { // Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation. let resets_at_ms = i64::MAX; - state - .session_limit_service - .confirm_human_resume(agent(8), node(80), None, resets_at_ms); + state.session_limit_service.confirm_human_resume( + project_id(1), + agent(8), + node(80), + None, + resets_at_ms, + ); let mut saw_rate_limited = false; let mut saw_scheduled = false; diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index b6a9344..cf2be4b 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -724,7 +724,9 @@ impl ChangeAgentProfile { // Résolution **polymorphe** de la session vivante sur les deux registres // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des // deux à la fois (invariant « 1 session/agent »). - let killed = self.kill_live_session(&input.agent_id).await?; + let killed = self + .kill_live_session(&input.project, &input.agent_id) + .await?; let Some(node_id) = killed else { // Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer. return Ok(None); @@ -776,12 +778,15 @@ impl ChangeAgentProfile { /// node neuf côté relance via `LaunchAgentInput.node_id = None`. async fn kill_live_session( &self, + project: &Project, agent_id: &AgentId, ) -> Result>, AppError> { // 1. Session structurée vivante ? ⇒ shutdown polymorphe. if let Some(structured) = &self.structured { - if let Some(session_id) = structured.session_id_for_agent(agent_id) { - let node_id = structured.node_for_agent(agent_id); + if let Some(session_id) = + structured.session_id_for_agent_in_project(project.id, agent_id) + { + let node_id = structured.node_for_agent_in_project(project.id, agent_id); if let Some(session) = structured.remove(&session_id) { session .shutdown() @@ -793,10 +798,15 @@ impl ChangeAgentProfile { } // 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique). - let Some(session_id) = self.sessions.session_for_agent(agent_id) else { + let Some(session_id) = self + .sessions + .session_for_agent_in_project(project.id, agent_id) + else { return Ok(None); }; - let node_id = self.sessions.node_for_agent(agent_id); + let node_id = self + .sessions + .node_for_agent_in_project(project.id, agent_id); if let Some(handle) = self.sessions.remove(&session_id) { self.pty.kill(&handle).await?; } @@ -1506,9 +1516,13 @@ impl LaunchAgent { // (rend la session existante, pas de respawn) ; // - **second lancement neuf** : on vise un **autre** node, sans signal de // réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté). - let existing_pty = self.sessions.session_for_agent(&input.agent_id); + let existing_pty = self + .sessions + .session_for_agent_in_project(input.project.id, &input.agent_id); if let Some(existing_id) = existing_pty { - let host_node = self.sessions.node_for_agent(&input.agent_id); + let host_node = self + .sessions + .node_for_agent_in_project(input.project.id, &input.agent_id); if input.allow_structured_alongside_pty && input.node_id.is_none() { crate::diag!( "[launch] existing PTY kept while structured launch proceeds: agent={} \ @@ -1519,9 +1533,11 @@ impl LaunchAgent { match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref()) { ReattachDecision::Rebind { node_id } => { - if let Some(session) = - self.sessions.rebind_agent_node(&input.agent_id, node_id) - { + if let Some(session) = self.sessions.rebind_agent_node_in_project( + input.project.id, + &input.agent_id, + node_id, + ) { return Ok(LaunchAgentOutput { session, assigned_conversation_id: None, @@ -1558,15 +1574,22 @@ impl LaunchAgent { // façon identique — rebind de la cellule-vue pour une réattache légitime, // idempotence sans node/conversation, refus d'un second lancement neuf ailleurs. if let Some(structured) = &self.structured { - if let Some(existing) = structured.session_for_agent(&input.agent_id) { - let host_node = structured.node_for_agent(&input.agent_id); + if let Some(existing) = + structured.session_for_agent_in_project(input.project.id, &input.agent_id) + { + let host_node = + structured.node_for_agent_in_project(input.project.id, &input.agent_id); let node_id = match reattach_decision( input.node_id, host_node, input.conversation_id.as_deref(), ) { ReattachDecision::Rebind { node_id } => { - let _ = structured.rebind_agent_node(&input.agent_id, node_id); + let _ = structured.rebind_agent_node_in_project( + input.project.id, + &input.agent_id, + node_id, + ); node_id } // Idempotent — garder le node hôte courant, sinon un node neuf. @@ -1772,7 +1795,8 @@ impl LaunchAgent { // l'orchestrateur). C'est cet id — et **non** l'id de session moteur — // qui retrouve log + handoff au (re)lancement (P7) et survit au swap. let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { - ConversationId::for_pair( + ConversationId::for_project_pair( + input.project.id, ConversationParty::User, ConversationParty::agent(agent.id), ) @@ -1788,6 +1812,7 @@ impl LaunchAgent { &run_dir, &session_plan, pair_conversation_id, + input.project.id, &input.project.root, input.node_id, size, @@ -1824,7 +1849,8 @@ impl LaunchAgent { size, ); session.status = SessionStatus::Running; - self.sessions.insert(handle, session.clone()); + self.sessions + .insert_in_project(input.project.id, handle, session.clone()); self.events.publish(DomainEvent::AgentLaunched { agent_id: agent.id, @@ -1863,6 +1889,7 @@ impl LaunchAgent { run_dir: &ProjectPath, session_plan: &SessionPlan, pair_conversation_id: String, + project_id: domain::ProjectId, root: &ProjectPath, node_id: Option, size: PtySize, @@ -1889,7 +1916,7 @@ impl LaunchAgent { // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en // amont sur les deux registres). - structured.insert(Arc::clone(&session), agent.id, node_id); + structured.insert_in_project(project_id, Arc::clone(&session), agent.id, node_id); // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ── // - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur diff --git a/crates/application/src/agent/session_limit.rs b/crates/application/src/agent/session_limit.rs index 1b89546..f87bd8d 100644 --- a/crates/application/src/agent/session_limit.rs +++ b/crates/application/src/agent/session_limit.rs @@ -23,7 +23,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use domain::ids::{AgentId, NodeId, ScheduleId}; +use domain::ids::{AgentId, NodeId, ProjectId, RuntimeAgentKey, ScheduleId}; use domain::ports::{Clock, EventBus, ScheduledTask, Scheduler}; use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; use domain::DomainEvent; @@ -57,6 +57,7 @@ pub trait AgentResumer: Send + Sync { /// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`. async fn resume( &self, + project_id: ProjectId, agent_id: AgentId, node_id: NodeId, conversation_id: Option, @@ -71,7 +72,7 @@ pub struct SessionLimitService { events: Arc, resumer: Arc, /// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire). - armed: Mutex>, + armed: Mutex>, } impl SessionLimitService { @@ -106,6 +107,7 @@ impl SessionLimitService { /// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement). pub fn on_rate_limited( &self, + project_id: ProjectId, agent_id: AgentId, node_id: NodeId, conversation_id: Option, @@ -119,7 +121,14 @@ impl SessionLimitService { fire_at_ms, conversation_id, } => { - self.arm_scheduled(agent_id, fire_at_ms, node_id, conversation_id, resets_at_ms); + self.arm_scheduled( + project_id, + agent_id, + fire_at_ms, + node_id, + conversation_id, + resets_at_ms, + ); } ResumePlan::HumanFallback => { self.events.publish(DomainEvent::AgentRateLimited { @@ -147,6 +156,7 @@ impl SessionLimitService { /// `Some`) ; on le traite en no-op défensif pour rester total. pub fn confirm_human_resume( &self, + project_id: ProjectId, agent_id: AgentId, node_id: NodeId, conversation_id: Option, @@ -161,6 +171,7 @@ impl SessionLimitService { } = plan_resume(now, &limit, conversation_id) { self.arm_scheduled( + project_id, agent_id, fire_at_ms, node_id, @@ -183,6 +194,7 @@ impl SessionLimitService { /// publiant l'heure de reset brute, pas l'échéance clampée. fn arm_scheduled( &self, + project_id: ProjectId, agent_id: AgentId, fire_at_ms: i64, node_id: NodeId, @@ -195,10 +207,12 @@ impl SessionLimitService { }); // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule // l'armement précédent (sans événement d'annulation : c'est interne). - self.disarm(agent_id); + let key = RuntimeAgentKey::new(project_id, agent_id); + self.disarm(key); let id = self.scheduler.arm( fire_at_ms, ScheduledTask::ResumeAgent { + project_id, agent_id, node_id, conversation_id, @@ -207,7 +221,7 @@ impl SessionLimitService { self.armed .lock() .expect("session-limit mutex sain") - .insert(agent_id, id); + .insert(key, id); self.events.publish(DomainEvent::AgentResumeScheduled { agent_id, fire_at_ms, @@ -225,16 +239,23 @@ impl SessionLimitService { /// ce cas `AgentResumed` n'est **pas** publié. pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> { let ScheduledTask::ResumeAgent { + project_id, agent_id, node_id, conversation_id, } = task; // Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non). - self.disarm(agent_id); + self.disarm(RuntimeAgentKey::new(project_id, agent_id)); self.resumer - .resume(agent_id, node_id, conversation_id, RESUME_PROMPT) + .resume( + project_id, + agent_id, + node_id, + conversation_id, + RESUME_PROMPT, + ) .await?; self.events.publish(DomainEvent::AgentResumed { agent_id }); @@ -254,21 +275,28 @@ impl SessionLimitService { /// en cours la retirera) : la reprise **suit son cours**, cohérent et sans /// événement trompeur. pub fn cancel_resume(&self, agent_id: AgentId) -> bool { - let id = self - .armed - .lock() - .expect("session-limit mutex sain") - .get(&agent_id) - .copied(); - let Some(id) = id else { - return false; // aucune reprise armée pour cet agent. + let ids = { + let armed = self.armed.lock().expect("session-limit mutex sain"); + armed + .iter() + .filter_map(|(key, id)| (key.agent_id == agent_id).then_some((*key, *id))) + .collect::>() }; + if ids.is_empty() { + return false; // aucune reprise armée pour cet agent. + } - if self.scheduler.cancel(id) { - self.armed - .lock() - .expect("session-limit mutex sain") - .remove(&agent_id); + let mut cancelled_any = false; + for (key, id) in ids { + if self.scheduler.cancel(id) { + self.armed + .lock() + .expect("session-limit mutex sain") + .remove(&key); + cancelled_any = true; + } + } + if cancelled_any { self.events .publish(DomainEvent::AgentResumeCancelled { agent_id }); true @@ -281,12 +309,12 @@ impl SessionLimitService { /// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent /// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie /// aucun événement** (contrairement à [`Self::cancel_resume`]). - fn disarm(&self, agent_id: AgentId) { + fn disarm(&self, key: RuntimeAgentKey) { let previous = self .armed .lock() .expect("session-limit mutex sain") - .remove(&agent_id); + .remove(&key); if let Some(id) = previous { self.scheduler.cancel(id); } diff --git a/crates/application/src/agent/structured.rs b/crates/application/src/agent/structured.rs index 84acd95..9df7bb4 100644 --- a/crates/application/src/agent/structured.rs +++ b/crates/application/src/agent/structured.rs @@ -17,7 +17,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use domain::conversation::ConversationParty; use domain::events::DomainEvent; -use domain::ids::AgentId; +use domain::ids::{AgentId, RuntimeAgentKey}; use domain::input::InputMediator; use domain::mailbox::TicketId; use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyEvent, ReplyStream}; @@ -147,7 +147,7 @@ pub async fn drain_with_readiness( prompt: &str, timeout: Option, mediator: &dyn InputMediator, - agent: AgentId, + agent: RuntimeAgentKey, ) -> Result { match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? { TurnOutcome::Completed(content) => Ok(content), @@ -164,7 +164,7 @@ pub async fn drain_with_readiness_and_announcements( prompt: &str, timeout: Option, mediator: &dyn InputMediator, - agent: AgentId, + agent: RuntimeAgentKey, announcements: Option, ) -> Result { match drain_with_readiness_and_announcements_outcome( @@ -191,7 +191,7 @@ pub async fn drain_with_readiness_and_announcements_outcome( prompt: &str, timeout: Option, mediator: &dyn InputMediator, - agent: AgentId, + agent: RuntimeAgentKey, announcements: Option, ) -> Result { let Some(publisher) = announcements else { @@ -239,7 +239,7 @@ pub async fn drain_with_readiness_and_announcements_outcome( pub async fn drain_reply_stream_with_readiness( stream: ReplyStream, mediator: &dyn InputMediator, - agent: AgentId, + agent: RuntimeAgentKey, ) -> Result { match drain_stream_to_final( stream, @@ -283,7 +283,7 @@ pub async fn drain_with_readiness_outcome( prompt: &str, timeout: Option, mediator: &dyn InputMediator, - agent: AgentId, + agent: RuntimeAgentKey, ) -> Result { // `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) : // la readiness ne classe pas les non-terminaux. Pour le **battement** de vivacité @@ -416,6 +416,10 @@ mod tests { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn key(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new(ProjectId::from_uuid(uuid::Uuid::nil()), agent(n)) + } + /// Session factice : `send` rejoue une liste fixe d'événements (terminée par un /// `Final`). struct FakeSession { @@ -446,23 +450,23 @@ mod tests { calls: Mutex>, } impl InputMediator for RecordingMediator { - fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply { unreachable!("non utilisé par drain_with_readiness") } - fn preempt(&self, _agent: AgentId) {} - fn mark_idle(&self, _agent: AgentId) { + fn preempt(&self, _agent: RuntimeAgentKey) {} + fn mark_idle(&self, _agent: RuntimeAgentKey) { self.calls.lock().unwrap().push("idle"); } - fn mark_alive(&self, _agent: AgentId) { + fn mark_alive(&self, _agent: RuntimeAgentKey) { self.calls.lock().unwrap().push("alive"); } - fn busy_state(&self, _agent: AgentId) -> AgentBusyState { + fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState { AgentBusyState::Idle } - fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} + fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {} fn bind_handle_with_submit( &self, - _agent: AgentId, + _agent: RuntimeAgentKey, _handle: PtyHandle, _submit: SubmitConfig, ) { @@ -498,7 +502,7 @@ mod tests { ], }; let mediator = RecordingMediator::default(); - let out = drain_with_readiness(&session, "go", None, &mediator, agent(1)) + let out = drain_with_readiness(&session, "go", None, &mediator, key(1)) .await .expect("drain ok"); assert_eq!(out, "fini"); @@ -534,7 +538,7 @@ mod tests { "go", None, &mediator, - target, + RuntimeAgentKey::new(project_id, target), Some(AnnouncementPublisher { bus: bus.clone(), project_id, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 6340a1d..ddc49b0 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -58,8 +58,8 @@ pub use agent::{ SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, - UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, - CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, + UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, + LIVE_STATE_INJECT_MAX, RESUME_PROMPT, }; pub use background::{ BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 003352b..7a85fb3 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -29,7 +29,7 @@ use domain::project::ProjectPath; use domain::{ AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand, - OrchestratorVisibility, ProfileId, Project, TaskId, + OrchestratorVisibility, ProfileId, Project, RuntimeAgentKey, TaskId, }; use crate::conversation::RecordTurn; @@ -59,6 +59,10 @@ const DEFAULT_ROWS: u16 = 24; /// See [`DEFAULT_ROWS`]. const DEFAULT_COLS: u16 = 80; +fn runtime_key(project: &Project, agent_id: AgentId) -> RuntimeAgentKey { + RuntimeAgentKey::new(project.id, agent_id) +} + /// Submit defaults for delegated prompts, after applying profile-specific /// compatibility fallbacks for existing saved profiles. fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { @@ -234,7 +238,7 @@ fn resolve_turn_timeout(turn_timeout_ms: Option) -> Duration { struct BusyTurnGuard { input: Arc, mailbox: Arc, - agent: AgentId, + agent: RuntimeAgentKey, ticket: TicketId, armed: bool, } @@ -244,7 +248,7 @@ impl BusyTurnGuard { fn new( input: Arc, mailbox: Arc, - agent: AgentId, + agent: RuntimeAgentKey, ticket: TicketId, ) -> Self { Self { @@ -279,7 +283,7 @@ impl Drop for BusyTurnGuard { // blocage à diagnostiquer. crate::diag!( "[rendezvous] busy-guard freed target agent {} (ticket {})", - self.agent, + self.agent.agent_id, self.ticket, ); } @@ -1116,7 +1120,7 @@ impl OrchestratorService { from, ticket, result, - } => self.reply(from, ticket, result), + } => self.reply(project, from, ticket, result), OrchestratorCommand::ListAgents => self.list_agents(project).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::UpdateAgentContext { name, context } => { @@ -1501,7 +1505,10 @@ impl OrchestratorService { } }; - if let Some(session_id) = self.sessions.session_for_agent(&agent_id) { + if let Some(session_id) = self + .sessions + .session_for_agent_in_project(project.id, &agent_id) + { match visibility { OrchestratorVisibility::Background => { return Ok(OrchestratorOutcome { @@ -1516,12 +1523,14 @@ impl OrchestratorService { // hôte est légitime (rebind de vue), mais viser un **autre** node // pour un agent singleton déjà vivant est un second lancement ⇒ // refus `AgentAlreadyRunning`. - let host_node = self.sessions.node_for_agent(&agent_id); + let host_node = self + .sessions + .node_for_agent_in_project(project.id, &agent_id); match ReattachDecision::resolve(Some(node_id), host_node, None) { ReattachDecision::Rebind { node_id } => { let session = self .sessions - .rebind_agent_node(&agent_id, node_id) + .rebind_agent_node_in_project(project.id, &agent_id, node_id) .ok_or_else(|| { AppError::NotFound(format!( "running session {session_id} for agent {name}" @@ -1620,7 +1629,6 @@ impl OrchestratorService { .await? .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; let agent_id = agent.id; - // Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle // d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock. @@ -1647,7 +1655,7 @@ impl OrchestratorService { // Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon // User↔B. La session vivante est désormais keyée par conversation (lève // `session-registry-agent-ambiguity`). - let conversation_id = self.resolve_conversation(requester, agent_id); + let conversation_id = self.resolve_conversation(project, requester, agent_id); // Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu // pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return. @@ -1751,6 +1759,7 @@ impl OrchestratorService { )) } }; + let agent_key = runtime_key(project, agent_id); // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket. let prompt_source = match requester { @@ -1783,7 +1792,7 @@ impl OrchestratorService { // Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT // l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité). let turn_timeout = self.turn_timeout_for(project, agent_id).await; - let _pending = input.enqueue_silent(agent_id, ticket); + let _pending = input.enqueue_silent(agent_key, ticket); let rendezvous_task = self .start_rendezvous_task( project, @@ -1809,7 +1818,7 @@ impl OrchestratorService { // (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); + BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_key, ticket_id); // Rendezvous beacon (chemin structuré) : équivalent du « ask started » du chemin // PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final` @@ -1840,7 +1849,7 @@ impl OrchestratorService { &task, None, input.as_ref(), - agent_id, + agent_key, announcement_publisher, ); @@ -1865,8 +1874,11 @@ impl OrchestratorService { if let (Some(service), Some(structured)) = (&self.session_limits, &self.structured) { - if let Some(node_id) = structured.node_for_agent(&agent_id) { + if let Some(node_id) = + structured.node_for_agent_in_project(project.id, &agent_id) + { service.on_rate_limited( + project.id, agent_id, node_id, conversation_id, @@ -1982,8 +1994,8 @@ impl OrchestratorService { // Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII. - mailbox.cancel_head(agent_id, ticket_id); - input.mark_idle(agent_id); + mailbox.cancel_head(agent_key, ticket_id); + input.mark_idle(agent_key); busy_guard.disarm(); // Checkpoint Response (best-effort), AVANT de déplacer `result`. @@ -2046,7 +2058,8 @@ impl OrchestratorService { let target = target.as_str(); // User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask. - let conversation_id = self.resolve_conversation(None, agent_id); + let agent_key = runtime_key(project, agent_id); + let conversation_id = self.resolve_conversation(project, None, agent_id); // Ensure the target is live for this thread and bind its input handle on the // mediator (delivery path). Same call the ask path uses. @@ -2061,15 +2074,15 @@ impl OrchestratorService { // de gate (livraison immédiate, sinon blocage indéfini). let gate_cold_start = cold_launch && has_mcp; if gate_cold_start { - input.mark_starting(agent_id); + input.mark_starting(agent_key); } - input.bind_handle_with_submit(agent_id, handle, submit); + input.bind_handle_with_submit(agent_key, handle, submit); // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and- // forget: we drop the PendingReply (the human reads the terminal). The // mediator emits AgentBusyChanged at the source on a starting turn. let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text); - let _pending = input.enqueue(agent_id, ticket); + let _pending = input.enqueue(agent_key, ticket); Ok(OrchestratorOutcome { detail: format!("submitted human input to agent {target}"), @@ -2110,7 +2123,7 @@ impl OrchestratorService { return Err(AppError::NotFound(format!("agent {agent_id}"))); } - input.preempt(agent_id); + input.preempt(runtime_key(project, agent_id)); Ok(OrchestratorOutcome { detail: format!("interrupted agent {agent_id}"), @@ -2147,9 +2160,9 @@ impl OrchestratorService { /// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se /// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le /// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé. - pub fn release_agent_cold_start(&self, agent: domain::AgentId) { + pub fn release_agent_cold_start(&self, project: &Project, agent: domain::AgentId) { if let Some(input) = &self.input { - input.release_cold_start(agent); + input.release_cold_start(runtime_key(project, agent)); } } @@ -2174,10 +2187,15 @@ impl OrchestratorService { /// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un /// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire /// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur. - pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) { + pub fn set_agent_front_attached( + &self, + project: &Project, + agent: domain::AgentId, + attached: bool, + ) { crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}"); if let Some(input) = &self.input { - input.set_front_attached(agent, attached); + input.set_front_attached(runtime_key(project, agent), attached); } else { crate::diag!( "[delivery] front attachment ignored because input mediator is not wired: \ @@ -2191,6 +2209,7 @@ impl OrchestratorService { /// stable per-agent id derived from the target (legacy routing — never panics). fn resolve_conversation( &self, + project: &Project, requester: Option, target: AgentId, ) -> domain::conversation::ConversationId { @@ -2200,11 +2219,11 @@ impl OrchestratorService { }; let right = ConversationParty::agent(target); match &self.conversations { - Some(reg) => reg.resolve(left, right).id, + Some(reg) => reg.resolve(project.id, left, right).id, // Repli pur déterministe partagé avec `LaunchAgent` (ARCHITECTURE §19.7, // lot P8a) : la même paire dérive la même clé de conversation des deux // côtés (sauvegarde du handoff ici, dérivation côté cellule là-bas). - None => domain::conversation::ConversationId::for_pair(left, right), + None => domain::conversation::ConversationId::for_project_pair(project.id, left, right), } } @@ -2223,6 +2242,7 @@ impl OrchestratorService { /// matching ask) — typed, never a panic. fn reply( &self, + project: &Project, from: AgentId, ticket: Option, result: String, @@ -2243,11 +2263,12 @@ impl OrchestratorService { "idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(), ) })?; + let from_key = runtime_key(project, from); // 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). let correlation = match ticket { - Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), - None => mailbox.resolve(from, result), + Some(ticket_id) => mailbox.resolve_ticket(from_key, ticket_id, result), + None => mailbox.resolve(from_key, 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 @@ -2269,7 +2290,7 @@ impl OrchestratorService { // pairs with prompt-ready detection; whichever fires first frees the turn. No-op // (and no spurious event) when the mediator is absent or `from` was already idle. if let Some(input) = self.input.as_ref() { - input.mark_idle(from); + input.mark_idle(from_key); } Ok(OrchestratorOutcome { detail: format!("reply from agent {from} delivered"), @@ -2300,10 +2321,10 @@ impl OrchestratorService { // «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la // session du **fil**, puis on retombe sur la session de l'agent (compat : un // agent mono-fil dont la session n'a pas encore été liée à sa conversation). - let existing = self - .sessions - .session_for(conversation_id) - .or_else(|| self.sessions.session_for_agent(&agent_id)); + let existing = self.sessions.session_for(conversation_id).or_else(|| { + self.sessions + .session_for_agent_in_project(project.id, &agent_id) + }); if let Some(session_id) = existing { if let Some(handle) = self.sessions.handle(&session_id) { // (Re)lier le fil à cette session vivante (idempotent). Réutilisation @@ -2334,11 +2355,14 @@ impl OrchestratorService { }) .await?; - let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| { - AppError::Process(format!( - "agent {target} n'a pas de session terminal vivante après lancement" - )) - })?; + let session_id = self + .sessions + .session_for_agent_in_project(project.id, &agent_id) + .ok_or_else(|| { + AppError::Process(format!( + "agent {target} n'a pas de session terminal vivante après lancement" + )) + })?; // Lier la session fraîchement lancée à CE fil (registre terminal + registre de // conversations) ⇒ un prochain ask sur le même fil la réutilise. self.bind_conversation_session(conversation_id, session_id); @@ -2373,7 +2397,7 @@ impl OrchestratorService { structured: &Arc, ) -> Result>, AppError> { // Cible déjà chaude : route directe (aucun lancement). - if let Some(session) = structured.session_for_agent(&agent_id) { + if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) { return Ok(Some(session)); } @@ -2413,7 +2437,7 @@ impl OrchestratorService { // Le launcher a inséré la session dans le registre partagé : la relire. structured - .session_for_agent(&agent_id) + .session_for_agent_in_project(project.id, &agent_id) .map(Some) .ok_or_else(|| { AppError::Process(format!( @@ -2528,7 +2552,7 @@ impl OrchestratorService { let session_id = self .sessions - .session_for_agent(&agent_id) + .session_for_agent_in_project(project.id, &agent_id) .ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?; self.close_terminal @@ -2719,7 +2743,7 @@ impl OrchestratorService { async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration { let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await; if let Some(input) = &self.input { - input.set_stall_threshold(agent_id, stall_after_ms); + input.set_stall_threshold(runtime_key(project, agent_id), stall_after_ms); } resolve_turn_timeout(turn_timeout_ms) } @@ -2982,18 +3006,18 @@ mod tests { impl domain::input::InputMediator for SpyMediator { fn enqueue( &self, - _agent: AgentId, + _agent: RuntimeAgentKey, _ticket: domain::mailbox::Ticket, ) -> domain::mailbox::PendingReply { domain::mailbox::PendingReply::new(Box::pin(async { Err(domain::mailbox::MailboxError::Cancelled) })) } - fn preempt(&self, _agent: AgentId) {} - fn mark_idle(&self, agent: AgentId) { - self.idled.lock().unwrap().push(agent); + fn preempt(&self, _agent: RuntimeAgentKey) {} + fn mark_idle(&self, agent: RuntimeAgentKey) { + self.idled.lock().unwrap().push(agent.agent_id); } - fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState { + fn busy_state(&self, _agent: RuntimeAgentKey) -> domain::input::AgentBusyState { domain::input::AgentBusyState::Idle } } @@ -3005,7 +3029,7 @@ mod tests { impl domain::mailbox::AgentMailbox for SpyMailbox { fn enqueue( &self, - _agent: AgentId, + _agent: RuntimeAgentKey, _ticket: domain::mailbox::Ticket, ) -> domain::mailbox::PendingReply { domain::mailbox::PendingReply::new(Box::pin(async { @@ -3014,13 +3038,16 @@ mod tests { } fn resolve( &self, - _agent: AgentId, + _agent: RuntimeAgentKey, _result: String, ) -> Result<(), domain::mailbox::MailboxError> { Ok(()) } - fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { - self.cancelled.lock().unwrap().push((agent, ticket_id)); + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { + self.cancelled + .lock() + .unwrap() + .push((agent.agent_id, ticket_id)); } } @@ -3030,6 +3057,9 @@ mod tests { fn tid(n: u128) -> TicketId { TicketId::from_uuid(uuid::Uuid::from_u128(n)) } + fn rkey(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new(domain::ProjectId::from_uuid(uuid::Uuid::nil()), aid(n)) + } /// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le /// comportement qui débloque un agent resté `Busy` sur un futur abandonné). @@ -3041,7 +3071,7 @@ mod tests { let _g = BusyTurnGuard::new( Arc::clone(&med) as Arc, Arc::clone(&mb) as Arc, - aid(1), + rkey(1), tid(7), ); } // Drop ici. @@ -3066,7 +3096,7 @@ mod tests { let g = BusyTurnGuard::new( Arc::clone(&med) as Arc, Arc::clone(&mb) as Arc, - aid(1), + rkey(1), tid(7), ); g.disarm(); diff --git a/crates/application/src/orchestrator/wake.rs b/crates/application/src/orchestrator/wake.rs index b48f1fb..3cf82a5 100644 --- a/crates/application/src/orchestrator/wake.rs +++ b/crates/application/src/orchestrator/wake.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use async_trait::async_trait; use domain::background_task::{BackgroundTask, BackgroundTaskResult}; use domain::events::DomainEvent; -use domain::ids::AgentId; +use domain::ids::{AgentId, RuntimeAgentKey}; use domain::inbox::{AgentInbox, InboxItem, InboxItemKind, InboxSource}; use domain::input::InputMediator; use domain::mailbox::{AgentMailbox, Ticket}; @@ -90,25 +90,26 @@ impl AgentWakeService { agent: AgentId, reason: WakeReason, ) -> Result<(), WakeError> { + let key = RuntimeAgentKey::new(project.id, agent); self.publish(DomainEvent::AgentWakeScheduled { project_id: project.id, agent_id: agent, }); - if self.input.busy_state(agent).is_busy() { + if self.input.busy_state(key).is_busy() { return Err(WakeError::AgentBusy { agent_id: agent }); } - let Some(item) = self.inbox.dequeue_next(agent) else { + let Some(item) = self.inbox.dequeue_next(key) else { return Ok(()); }; let delivery = self.delivery_from_item(item, &reason).await?; let ticket = Ticket::new(delivery.ticket_id, "IdeA", delivery.prompt.clone()); - let _pending = self.input.enqueue_silent(agent, ticket); + let _pending = self.input.enqueue_silent(key, ticket); let guard = WakeTurnGuard::new( Arc::clone(&self.input), Arc::clone(&self.mailbox), - agent, + key, delivery.ticket_id, ); @@ -133,12 +134,12 @@ impl AgentWakeService { owner_agent_id: agent, }); } - drain_reply_stream_with_readiness(stream, self.input.as_ref(), agent) + drain_reply_stream_with_readiness(stream, self.input.as_ref(), key) .await .map_err(|err| WakeError::Session(err.to_string()))?; - self.mailbox.cancel_head(agent, delivery.ticket_id); - self.input.mark_idle(agent); + self.mailbox.cancel_head(key, delivery.ticket_id); + self.input.mark_idle(key); guard.disarm(); Ok(()) } @@ -220,7 +221,7 @@ struct WakeDelivery { struct WakeTurnGuard { input: Arc, mailbox: Arc, - agent: AgentId, + agent: RuntimeAgentKey, ticket: domain::mailbox::TicketId, armed: bool, } @@ -229,7 +230,7 @@ impl WakeTurnGuard { fn new( input: Arc, mailbox: Arc, - agent: AgentId, + agent: RuntimeAgentKey, ticket: domain::mailbox::TicketId, ) -> Self { Self { diff --git a/crates/application/src/terminal/registry.rs b/crates/application/src/terminal/registry.rs index 40cfb79..7ffff67 100644 --- a/crates/application/src/terminal/registry.rs +++ b/crates/application/src/terminal/registry.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use domain::conversation::ConversationId; use domain::ports::{AgentSession, PtyHandle}; -use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession}; +use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession}; /// Runtime family of a live agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -25,6 +25,8 @@ pub enum LiveSessionKind { /// Read-only coordinates of one live agent session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LiveSessionSnapshot { + /// The project owning the live session. + pub project_id: ProjectId, /// The agent owning the live session. pub agent_id: AgentId, /// The layout node currently hosting the session view. @@ -38,6 +40,7 @@ pub struct LiveSessionSnapshot { /// A registered, live terminal: its PTY handle plus the domain snapshot. #[derive(Debug, Clone)] struct Entry { + project_id: ProjectId, handle: PtyHandle, session: TerminalSession, } @@ -51,7 +54,7 @@ struct Entry { /// implementation. pub trait LiveAgentRegistry: Send + Sync { /// Whether `agent_id` currently has a live session in the registry. - fn is_agent_live(&self, agent_id: &AgentId) -> bool; + fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool; /// Whether `node_id` (a layout leaf) currently hosts a live session. /// @@ -75,8 +78,9 @@ pub struct TerminalSessions { } impl LiveAgentRegistry for TerminalSessions { - fn is_agent_live(&self, agent_id: &AgentId) -> bool { - self.session_for_agent(agent_id).is_some() + fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool { + self.session_for_agent_in_project(project_id, agent_id) + .is_some() } fn is_node_live(&self, node_id: &NodeId) -> bool { @@ -130,11 +134,16 @@ impl TerminalSessions { /// may take part in several threads, so this is the **plural** of /// [`Self::session_for_agent`]). #[must_use] - pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec { + pub fn sessions_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Vec { self.entries .lock() .map(|m| { m.values() + .filter(|e| e.project_id == project_id) .filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .map(|e| e.session.id) .collect() @@ -142,13 +151,38 @@ impl TerminalSessions { .unwrap_or_default() } + /// Legacy project-less test helper. Production code must use + /// [`Self::sessions_for_agent_in_project`]. + #[must_use] + pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec { + self.sessions_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Inserts a freshly-opened session. - pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { + pub fn insert_in_project( + &self, + project_id: ProjectId, + handle: PtyHandle, + session: TerminalSession, + ) { if let Ok(mut map) = self.entries.lock() { - map.insert(session.id, Entry { handle, session }); + map.insert( + session.id, + Entry { + project_id, + handle, + session, + }, + ); } } + /// Legacy project-less test helper. Production code must use + /// [`Self::insert_in_project`]. + pub fn insert(&self, handle: PtyHandle, session: TerminalSession) { + self.insert_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), handle, session); + } + /// Returns the [`PtyHandle`] for a session, if registered. #[must_use] pub fn handle(&self, id: &SessionId) -> Option { @@ -179,14 +213,26 @@ impl TerminalSessions { /// one live session per agent, so the first match is *the* match. `find` /// short-circuits on it. #[must_use] - pub fn session_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn session_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option { self.entries.lock().ok().and_then(|m| { m.values() + .filter(|e| e.project_id == project_id) .find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .map(|e| e.session.id) }) } + /// Legacy project-less test helper. Production code must use + /// [`Self::session_for_agent_in_project`]. + #[must_use] + pub fn session_for_agent(&self, agent_id: &AgentId) -> Option { + self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Returns the [`NodeId`] of the live cell hosting a given agent, if any. /// /// Companion to [`Self::session_for_agent`]: the launch guard needs the @@ -194,25 +240,41 @@ impl TerminalSessions { /// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same /// one-live-session-per-agent invariant. #[must_use] - pub fn node_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn node_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option { self.entries.lock().ok().and_then(|m| { m.values() + .filter(|e| e.project_id == project_id) .find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .map(|e| e.session.node_id) }) } + /// Legacy project-less test helper. Production code must use + /// [`Self::node_for_agent_in_project`]. + #[must_use] + pub fn node_for_agent(&self, agent_id: &AgentId) -> Option { + self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Lists every currently-live agent, its current host cell and session id. /// /// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`]. /// Used by the `list_live_agents` query so the UI can disable an agent that /// is already running elsewhere (it cannot be launched in a second cell). #[must_use] - pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { + pub fn live_agents_in_project( + &self, + project_id: ProjectId, + ) -> Vec<(AgentId, NodeId, SessionId)> { self.entries .lock() .map(|m| { m.values() + .filter(|e| e.project_id == project_id) .filter_map(|e| match e.session.kind { SessionKind::Agent { agent_id } => { Some((agent_id, e.session.node_id, e.session.id)) @@ -224,6 +286,13 @@ impl TerminalSessions { .unwrap_or_default() } + /// Legacy project-less test helper. Production code should prefer + /// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`]. + #[must_use] + pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { + self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil())) + } + /// Rebinds a live agent session to a new visible layout node without /// respawning the CLI process. /// @@ -232,20 +301,36 @@ impl TerminalSessions { /// in another cell updates only the view binding (`node_id`). The PTY handle, /// session id, scrollback and process stay untouched. #[must_use] - pub fn rebind_agent_node( + pub fn rebind_agent_node_in_project( &self, + project_id: ProjectId, agent_id: &AgentId, node_id: NodeId, ) -> Option { self.entries.lock().ok().and_then(|mut m| { let entry = m.values_mut().find( - |e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id), + |e| e.project_id == project_id + && matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id), )?; entry.session.node_id = node_id; Some(entry.session.clone()) }) } + /// Legacy project-less test helper. Production code must pass `project_id`. + #[must_use] + pub fn rebind_agent_node( + &self, + agent_id: &AgentId, + node_id: NodeId, + ) -> Option { + self.rebind_agent_node_in_project( + ProjectId::from_uuid(uuid::Uuid::nil()), + agent_id, + node_id, + ) + } + /// Returns the [`PtyHandle`]s of every currently-registered session. /// /// Used at application shutdown to kill all live PTYs cleanly (the @@ -295,6 +380,8 @@ impl TerminalSessions { /// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que /// [`TerminalSessions`]. struct StructuredEntry { + /// Projet propriétaire de la session runtime. + project_id: ProjectId, /// La session vivante (ressource process/SDK), derrière le port domaine. session: Arc, /// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »). @@ -328,8 +415,9 @@ pub struct StructuredSessions { } impl LiveAgentRegistry for StructuredSessions { - fn is_agent_live(&self, agent_id: &AgentId) -> bool { - self.session_for_agent(agent_id).is_some() + fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool { + self.session_for_agent_in_project(project_id, agent_id) + .is_some() } fn is_node_live(&self, node_id: &NodeId) -> bool { @@ -352,12 +440,19 @@ impl StructuredSessions { /// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par /// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]). - pub fn insert(&self, session: Arc, agent_id: AgentId, node_id: NodeId) { + pub fn insert_in_project( + &self, + project_id: ProjectId, + session: Arc, + agent_id: AgentId, + node_id: NodeId, + ) { if let Ok(mut map) = self.entries.lock() { let id = session.id(); map.insert( id, StructuredEntry { + project_id, session, agent_id, node_id, @@ -366,6 +461,17 @@ impl StructuredSessions { } } + /// Legacy project-less test helper. Production code must use + /// [`Self::insert_in_project`]. + pub fn insert(&self, session: Arc, agent_id: AgentId, node_id: NodeId) { + self.insert_in_project( + ProjectId::from_uuid(uuid::Uuid::nil()), + session, + agent_id, + node_id, + ); + } + /// Retourne la session enregistrée pour un id, si présente. #[must_use] pub fn session(&self, id: &SessionId) -> Option> { @@ -434,36 +540,72 @@ impl StructuredSessions { /// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par /// l'invariant « 1 session vivante/agent » — le premier match est *le* match. #[must_use] - pub fn session_for_agent(&self, agent_id: &AgentId) -> Option> { + pub fn session_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option> { self.entries.lock().ok().and_then(|m| { m.values() + .filter(|e| e.project_id == project_id) .find(|e| &e.agent_id == agent_id) .map(|e| Arc::clone(&e.session)) }) } + /// Legacy project-less test helper. Production code must use + /// [`Self::session_for_agent_in_project`]. + #[must_use] + pub fn session_for_agent(&self, agent_id: &AgentId) -> Option> { + self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any. #[must_use] - pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn session_id_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option { self.entries.lock().ok().and_then(|m| { m.values() + .filter(|e| e.project_id == project_id) .find(|e| &e.agent_id == agent_id) .map(|e| e.session.id()) }) } + /// Legacy project-less test helper. Production code must use + /// [`Self::session_id_for_agent_in_project`]. + #[must_use] + pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option { + self.session_id_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any. /// /// Jumeau de [`TerminalSessions::node_for_agent`]. #[must_use] - pub fn node_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn node_for_agent_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option { self.entries.lock().ok().and_then(|m| { m.values() + .filter(|e| e.project_id == project_id) .find(|e| &e.agent_id == agent_id) .map(|e| e.node_id) }) } + /// Legacy project-less test helper. Production code must use + /// [`Self::node_for_agent_in_project`]. + #[must_use] + pub fn node_for_agent(&self, agent_id: &AgentId) -> Option { + self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id) + } + /// Résout les coordonnées `(agent_id, node_id, conversation_id)` d'une session structurée par son /// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10). /// @@ -473,10 +615,19 @@ impl StructuredSessions { /// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur /// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante. #[must_use] - pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId, Option)> { + pub fn meta_for_session( + &self, + id: &SessionId, + ) -> Option<(ProjectId, AgentId, NodeId, Option)> { self.entries.lock().ok().and_then(|m| { - m.get(id) - .map(|e| (e.agent_id, e.node_id, e.session.conversation_id())) + m.get(id).map(|e| { + ( + e.project_id, + e.agent_id, + e.node_id, + e.session.conversation_id(), + ) + }) }) } @@ -485,17 +636,28 @@ impl StructuredSessions { /// Jumeau de [`TerminalSessions::live_agents`] : un tuple /// `(AgentId, NodeId, SessionId)` par session structurée vivante. #[must_use] - pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { + pub fn live_agents_in_project( + &self, + project_id: ProjectId, + ) -> Vec<(AgentId, NodeId, SessionId)> { self.entries .lock() .map(|m| { m.values() + .filter(|e| e.project_id == project_id) .map(|e| (e.agent_id, e.node_id, e.session.id())) .collect() }) .unwrap_or_default() } + /// Legacy project-less test helper. Production code should prefer + /// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`]. + #[must_use] + pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { + self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil())) + } + /// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans /// redémarrer la conversation (« la cellule est une vue », §17.6). /// @@ -503,16 +665,33 @@ impl StructuredSessions { /// change ; la session, son id et sa conversation restent intacts. Retourne la /// session rebindée, ou `None` si l'agent n'a pas de session vivante. #[must_use] + pub fn rebind_agent_node_in_project( + &self, + project_id: ProjectId, + agent_id: &AgentId, + node_id: NodeId, + ) -> Option> { + self.entries.lock().ok().and_then(|mut m| { + let entry = m + .values_mut() + .find(|e| e.project_id == project_id && &e.agent_id == agent_id)?; + entry.node_id = node_id; + Some(Arc::clone(&entry.session)) + }) + } + + /// Legacy project-less test helper. Production code must pass `project_id`. + #[must_use] pub fn rebind_agent_node( &self, agent_id: &AgentId, node_id: NodeId, ) -> Option> { - self.entries.lock().ok().and_then(|mut m| { - let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?; - entry.node_id = node_id; - Some(Arc::clone(&entry.session)) - }) + self.rebind_agent_node_in_project( + ProjectId::from_uuid(uuid::Uuid::nil()), + agent_id, + node_id, + ) } /// Retire une session du registre, retournant la session si présente (pour que @@ -605,25 +784,35 @@ impl LiveSessions { /// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée. #[must_use] - pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn session_id_for_agent( + &self, + project_id: ProjectId, + agent_id: &AgentId, + ) -> Option { self.pty - .session_for_agent(agent_id) - .or_else(|| self.structured.session_id_for_agent(agent_id)) + .session_for_agent_in_project(project_id, agent_id) + .or_else(|| { + self.structured + .session_id_for_agent_in_project(project_id, agent_id) + }) } /// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée. #[must_use] - pub fn node_for_agent(&self, agent_id: &AgentId) -> Option { + pub fn node_for_agent(&self, project_id: ProjectId, agent_id: &AgentId) -> Option { self.pty - .node_for_agent(agent_id) - .or_else(|| self.structured.node_for_agent(agent_id)) + .node_for_agent_in_project(project_id, agent_id) + .or_else(|| { + self.structured + .node_for_agent_in_project(project_id, agent_id) + }) } /// Tous les agents vivants des deux registres (PTY puis structurés). #[must_use] - pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { - let mut all = self.pty.live_agents(); - all.extend(self.structured.live_agents()); + pub fn live_agents(&self, project_id: ProjectId) -> Vec<(AgentId, NodeId, SessionId)> { + let mut all = self.pty.live_agents_in_project(project_id); + all.extend(self.structured.live_agents_in_project(project_id)); all } @@ -632,30 +821,48 @@ impl LiveSessions { pub fn live_agent_snapshots(&self) -> Vec { let mut all: Vec = self .pty - .live_agents() - .into_iter() - .map(|(agent_id, node_id, session_id)| LiveSessionSnapshot { - agent_id, - node_id, - session_id, - kind: LiveSessionKind::Pty, + .entries + .lock() + .map(|m| { + m.values() + .filter_map(|e| match e.session.kind { + SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot { + project_id: e.project_id, + agent_id, + node_id: e.session.node_id, + session_id: e.session.id, + kind: LiveSessionKind::Pty, + }), + SessionKind::Plain => None, + }) + .collect() }) - .collect(); - all.extend(self.structured.live_agents().into_iter().map( - |(agent_id, node_id, session_id)| LiveSessionSnapshot { - agent_id, - node_id, - session_id, - kind: LiveSessionKind::Structured, - }, - )); + .unwrap_or_default(); + all.extend( + self.structured + .entries + .lock() + .map(|m| { + m.values() + .map(|e| LiveSessionSnapshot { + project_id: e.project_id, + agent_id: e.agent_id, + node_id: e.node_id, + session_id: e.session.id(), + kind: LiveSessionKind::Structured, + }) + .collect::>() + }) + .unwrap_or_default(), + ); all } } impl LiveAgentRegistry for LiveSessions { - fn is_agent_live(&self, agent_id: &AgentId) -> bool { - self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id) + fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool { + self.pty.is_agent_live(project_id, agent_id) + || self.structured.is_agent_live(project_id, agent_id) } fn is_node_live(&self, node_id: &NodeId) -> bool { diff --git a/crates/application/src/workstate/actions.rs b/crates/application/src/workstate/actions.rs index 316ee02..5c5b8fa 100644 --- a/crates/application/src/workstate/actions.rs +++ b/crates/application/src/workstate/actions.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use domain::input::InputMediator; -use domain::{AgentId, NodeId, Project, SessionId}; +use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId}; use crate::error::AppError; use crate::orchestrator::OrchestratorService; @@ -65,11 +65,11 @@ impl AttachLiveAgent { /// [`AppError::NotFound`] when the agent has no live session in either registry. pub fn execute(&self, input: AttachLiveAgentInput) -> Result { // PTY first, then structured (one-live-session-per-agent ⇒ at most one match). - if let Some(session) = self - .live - .pty - .rebind_agent_node(&input.agent_id, input.node_id) - { + if let Some(session) = self.live.pty.rebind_agent_node_in_project( + input.project.id, + &input.agent_id, + input.node_id, + ) { return Ok(AttachLiveAgentOutput { agent_id: input.agent_id, node_id: session.node_id, @@ -77,11 +77,11 @@ impl AttachLiveAgent { kind: LiveSessionKind::Pty, }); } - if let Some(session) = self - .live - .structured - .rebind_agent_node(&input.agent_id, input.node_id) - { + if let Some(session) = self.live.structured.rebind_agent_node_in_project( + input.project.id, + &input.agent_id, + input.node_id, + ) { return Ok(AttachLiveAgentOutput { agent_id: input.agent_id, node_id: input.node_id, @@ -168,28 +168,37 @@ impl StopLiveAgent { &self, input: StopLiveAgentInput, ) -> Result { - self.stop_dependencies(input.agent_id).await; + self.stop_dependencies(&input.project, input.agent_id).await; if let Some(mediator) = &self.input { - mediator.preempt(input.agent_id); + mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id)); } - self.stop_one(input.agent_id).await + self.stop_one_in_project(&input.project, input.agent_id) + .await } - async fn stop_dependencies(&self, agent_id: AgentId) { + async fn stop_dependencies(&self, project: &Project, agent_id: AgentId) { let Some(waits) = &self.waits else { return; }; for dep in waits.active_wait_dependencies(agent_id) { if let Some(mediator) = &self.input { - mediator.preempt(dep); + mediator.preempt(RuntimeAgentKey::new(project.id, dep)); } - let _ = self.stop_one(dep).await; + let _ = self.stop_one_in_project(project, dep).await; } } - async fn stop_one(&self, agent_id: AgentId) -> Result { + async fn stop_one_in_project( + &self, + project: &Project, + agent_id: AgentId, + ) -> Result { // PTY first: delegate to the existing close primitive (removes + kills). - if let Some(session_id) = self.live.pty.session_for_agent(&agent_id) { + if let Some(session_id) = self + .live + .pty + .session_for_agent_in_project(project.id, &agent_id) + { self.close .execute(CloseTerminalInput { session_id }) .await?; @@ -201,7 +210,11 @@ impl StopLiveAgent { } // Structured: remove from the registry first (so the uniqueness guard no // longer sees a live session), then shut the session down out of the lock. - if let Some(session_id) = self.live.structured.session_id_for_agent(&agent_id) { + if let Some(session_id) = self + .live + .structured + .session_id_for_agent_in_project(project.id, &agent_id) + { if let Some(session) = self.live.structured.remove(&session_id) { session .shutdown() diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index 1daab65..b75a150 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -461,7 +461,12 @@ impl GetProjectWorkState { input: GetProjectWorkStateInput, ) -> Result { let manifest = self.contexts.load_manifest(&input.project).await?; - let live_by_agent = live_by_agent(self.live.live_agent_snapshots()); + let live_by_agent = live_by_agent( + self.live + .live_agent_snapshots() + .into_iter() + .filter(|snapshot| snapshot.project_id == input.project.id), + ); let undelivered_completions = match &self.background_tasks { Some(store) => Some(store.list_undelivered_completions().await), None => None, @@ -482,11 +487,12 @@ impl GetProjectWorkState { // Tickets are crossed with the busy state: only an agent absent from // the manifest is dropped (this loop only visits manifest entries), so // the manifest boundary is naturally preserved. - let busy = self.input.busy_state(agent.id); + let runtime_key = domain::RuntimeAgentKey::new(input.project.id, agent.id); + let busy = self.input.busy_state(runtime_key); let busy_ticket = busy.ticket(); let tickets = self .queue - .queue_for(agent.id) + .queue_for(runtime_key) .into_iter() .map(|snapshot| ticket_state(snapshot, busy_ticket)) .collect(); @@ -810,7 +816,9 @@ fn preview(text: &str, max_chars: usize) -> String { } } -fn live_by_agent(snapshots: Vec) -> HashMap { +fn live_by_agent( + snapshots: impl IntoIterator, +) -> HashMap { let mut out = HashMap::new(); for snapshot in snapshots { out.entry(snapshot.agent_id).or_insert(snapshot); diff --git a/crates/application/src/workstate/reconcile.rs b/crates/application/src/workstate/reconcile.rs index 47c4ca8..628f440 100644 --- a/crates/application/src/workstate/reconcile.rs +++ b/crates/application/src/workstate/reconcile.rs @@ -77,10 +77,11 @@ impl ReconcileLiveState { /// /// # Errors /// [`AppError::Store`] sur défaillance de chargement ou d'upsert du store. - pub async fn execute(&self, _input: ReconcileLiveStateInput) -> Result<(), AppError> { + pub async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> { let state = self.store.load().await?; let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); - let reconciled = state.reconcile_orphans(|a| self.registry.is_agent_live(a), now_ms); + let reconciled = + state.reconcile_orphans(|a| self.registry.is_agent_live(input.project_id, a), now_ms); for entry in reconciled { self.store.upsert(entry).await?; } @@ -125,7 +126,7 @@ mod tests { live: HashSet, } impl LiveAgentRegistry for FakeRegistry { - fn is_agent_live(&self, agent_id: &AgentId) -> bool { + fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool { self.live.contains(agent_id) } fn is_node_live(&self, _node_id: &NodeId) -> bool { diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index ba51572..dd79812 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -1174,7 +1174,7 @@ fn seed_live_agent_session( size, ); session.status = domain::SessionStatus::Running; - sessions.insert(PtyHandle { session_id }, session); + sessions.insert_in_project(project().id, PtyHandle { session_id }, session); } fn nid(n: u128) -> domain::NodeId { @@ -1220,7 +1220,7 @@ async fn launch_new_in_other_cell_refuses_when_agent_live_elsewhere() { // No silent move, no respawn, registry untouched. assert_eq!( - sessions.node_for_agent(&agent.id), + sessions.node_for_agent_in_project(project().id, &agent.id), Some(host), "session stays pinned on its host node" ); @@ -1309,7 +1309,10 @@ async fn launch_other_cell_with_conversation_id_rebinds_no_respawn() { assert_eq!(out.session.id, sid(42), "returns the existing session"); assert_eq!(out.session.node_id, target, "view rebound to target cell"); - assert_eq!(sessions.node_for_agent(&agent.id), Some(target)); + assert_eq!( + sessions.node_for_agent_in_project(project().id, &agent.id), + Some(target) + ); assert_eq!(sessions.len(), before, "registry size is unchanged"); assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach"); } @@ -1328,7 +1331,9 @@ async fn launch_succeeds_after_session_removed() { // Live, then removed (close/exit). seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); sessions.remove(&sid(42)); - assert!(sessions.session_for_agent(&agent.id).is_none()); + assert!(sessions + .session_for_agent_in_project(project().id, &agent.id) + .is_none()); let mut input = launch_input(agent.id); input.node_id = Some(nid(2)); diff --git a/crates/application/tests/agent_wake.rs b/crates/application/tests/agent_wake.rs index 02a9ee2..c698cae 100644 --- a/crates/application/tests/agent_wake.rs +++ b/crates/application/tests/agent_wake.rs @@ -7,7 +7,7 @@ use domain::background_task::{ BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, }; -use domain::ids::{AgentId, ProjectId, SessionId, TaskId}; +use domain::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId, TaskId}; use domain::inbox::{ AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt, InboxReceiptStatus, InboxSource, @@ -30,6 +30,10 @@ fn agent(n: u128) -> AgentId { AgentId::from_uuid(id(n)) } +fn runtime_key(agent_id: AgentId) -> RuntimeAgentKey { + RuntimeAgentKey::new(ProjectId::from_uuid(id(100)), agent_id) +} + fn task_id(n: u128) -> TaskId { TaskId::from_uuid(id(n)) } @@ -93,44 +97,46 @@ fn completed_task( #[derive(Default)] struct FakeInbox { - queues: Mutex>>, + queues: Mutex>>, } impl AgentInbox for FakeInbox { fn enqueue_message( &self, - agent_id: AgentId, + agent: RuntimeAgentKey, item: InboxItem, ) -> Result { let mut queues = self.queues.lock().unwrap(); - let queue = queues.entry(agent_id).or_default(); + let queue = queues.entry(agent).or_default(); let item_id = item.id; queue.push_back(item); Ok(InboxReceipt { item_id, - agent_id, + agent_id: agent.agent_id, + runtime_key: agent, depth: queue.len(), status: InboxReceiptStatus::Queued, }) } - fn dequeue_next(&self, agent_id: AgentId) -> Option { + fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option { self.queues .lock() .unwrap() - .entry(agent_id) + .entry(agent) .or_default() .pop_front() } - fn snapshot(&self, agent_id: AgentId) -> AgentInboxSnapshot { + fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot { let queues = self.queues.lock().unwrap(); let items = queues - .get(&agent_id) + .get(&agent) .map(|queue| queue.iter().cloned().collect::>()) .unwrap_or_default(); AgentInboxSnapshot { - agent_id, + agent_id: agent.agent_id, + runtime_key: agent, depth: items.len(), items, } @@ -139,14 +145,14 @@ impl AgentInbox for FakeInbox { #[derive(Default)] struct SharedTurnState { - busy: Mutex>, - tickets: Mutex>>, + busy: Mutex>, + tickets: Mutex>>, } impl SharedTurnState { fn force_busy(&self, agent_id: AgentId, ticket_id: TicketId) { self.busy.lock().unwrap().insert( - agent_id, + runtime_key(agent_id), AgentBusyState::Busy { ticket: ticket_id, since_ms: 1, @@ -158,20 +164,20 @@ impl SharedTurnState { self.tickets .lock() .unwrap() - .get(&agent_id) + .get(&runtime_key(agent_id)) .map(VecDeque::len) .unwrap_or_default() } } impl InputMediator for SharedTurnState { - fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply { - self.enqueue_silent(agent_id, ticket) + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { + self.enqueue_silent(agent, ticket) } - fn enqueue_silent(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { self.busy.lock().unwrap().insert( - agent_id, + agent, AgentBusyState::Busy { ticket: ticket.id, since_ms: 1, @@ -180,43 +186,43 @@ impl InputMediator for SharedTurnState { self.tickets .lock() .unwrap() - .entry(agent_id) + .entry(agent) .or_default() .push_back(ticket); PendingReply::new(Box::pin(async { Err(MailboxError::Cancelled) })) } - fn preempt(&self, _agent: AgentId) {} + fn preempt(&self, _agent: RuntimeAgentKey) {} - fn mark_idle(&self, agent_id: AgentId) { + fn mark_idle(&self, agent: RuntimeAgentKey) { self.busy .lock() .unwrap() - .insert(agent_id, AgentBusyState::Idle); + .insert(agent, AgentBusyState::Idle); } - fn busy_state(&self, agent_id: AgentId) -> AgentBusyState { + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState { self.busy .lock() .unwrap() - .get(&agent_id) + .get(&agent) .copied() .unwrap_or(AgentBusyState::Idle) } } impl AgentMailbox for SharedTurnState { - fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply { - ::enqueue(self, agent_id, ticket) + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { + ::enqueue(self, agent, ticket) } - fn resolve(&self, _agent: AgentId, _result: String) -> Result<(), MailboxError> { + fn resolve(&self, _agent: RuntimeAgentKey, _result: String) -> Result<(), MailboxError> { Ok(()) } - fn cancel_head(&self, agent_id: AgentId, ticket_id: TicketId) { + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { let mut tickets = self.tickets.lock().unwrap(); - if let Some(queue) = tickets.get_mut(&agent_id) { + if let Some(queue) = tickets.get_mut(&agent) { if queue.front().is_some_and(|ticket| ticket.id == ticket_id) { queue.pop_front(); } @@ -395,7 +401,10 @@ async fn wake_if_idle_starts_turn_with_background_completion_prompt() { let sessions = Arc::new(FakeSessionProvider::with_session(session.clone())); tasks.insert(completed_task(&project, owner, task_id, "build finished")); inbox - .enqueue_message(owner, completion_item(owner, task_id, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) .unwrap(); service(inbox, turns, tasks, sessions) @@ -427,7 +436,10 @@ async fn owner_busy_does_not_start_concurrent_wake_and_keeps_item_queued() { let sessions = Arc::new(FakeSessionProvider::with_session(session.clone())); tasks.insert(completed_task(&project, owner, task_id, "done")); inbox - .enqueue_message(owner, completion_item(owner, task_id, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) .unwrap(); turns.force_busy(owner, ticket(99)); @@ -442,7 +454,7 @@ async fn owner_busy_does_not_start_concurrent_wake_and_keeps_item_queued() { assert_eq!(err, WakeError::AgentBusy { agent_id: owner }); assert!(session.prompts.lock().unwrap().is_empty()); - assert_eq!(inbox.snapshot(owner).depth, 1); + assert_eq!(inbox.snapshot(runtime_key(owner)).depth, 1); } #[tokio::test] @@ -456,7 +468,10 @@ async fn absent_session_is_launched_or_reattached_by_provider() { let sessions = Arc::new(FakeSessionProvider::default()); tasks.insert(completed_task(&project, owner, task_id, "done")); inbox - .enqueue_message(owner, completion_item(owner, task_id, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) .unwrap(); service(inbox, turns, tasks, sessions.clone()) @@ -485,7 +500,10 @@ async fn completion_is_marked_delivered_after_successful_wake() { ))); tasks.insert(completed_task(&project, owner, task_id, "done")); inbox - .enqueue_message(owner, completion_item(owner, task_id, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) .unwrap(); service(inbox, turns, tasks.clone(), sessions) @@ -512,7 +530,10 @@ async fn completion_is_marked_delivered_once_send_is_accepted_even_if_drain_fail let sessions = Arc::new(FakeSessionProvider::with_session(session.clone())); tasks.insert(completed_task(&project, owner, task_id, "done")); inbox - .enqueue_message(owner, completion_item(owner, task_id, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, task_id, ticket(20)), + ) .unwrap(); let err = service(inbox, turns, tasks.clone(), sessions) @@ -543,10 +564,16 @@ async fn wake_drains_exactly_one_item_per_turn() { tasks.insert(completed_task(&project, owner, first, "first")); tasks.insert(completed_task(&project, owner, second, "second")); inbox - .enqueue_message(owner, completion_item(owner, first, ticket(20))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, first, ticket(20)), + ) .unwrap(); inbox - .enqueue_message(owner, completion_item(owner, second, ticket(21))) + .enqueue_message( + runtime_key(owner), + completion_item(owner, second, ticket(21)), + ) .unwrap(); service(inbox.clone(), turns.clone(), tasks, sessions) @@ -559,6 +586,6 @@ async fn wake_drains_exactly_one_item_per_turn() { .unwrap(); assert_eq!(session.prompts.lock().unwrap().len(), 1); - assert_eq!(inbox.snapshot(owner).depth, 1); + assert_eq!(inbox.snapshot(runtime_key(owner)).depth, 1); assert_eq!(turns.ticket_depth(owner), 0); } diff --git a/crates/application/tests/change_agent_profile.rs b/crates/application/tests/change_agent_profile.rs index ddf792d..b498852 100644 --- a/crates/application/tests/change_agent_profile.rs +++ b/crates/application/tests/change_agent_profile.rs @@ -670,7 +670,7 @@ fn seed_live_agent_session( size, ); session.status = domain::SessionStatus::Running; - sessions.insert(PtyHandle { session_id }, session); + sessions.insert_in_project(project().id, PtyHandle { session_id }, session); } // --------------------------------------------------------------------------- @@ -1580,7 +1580,8 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() { SessionKind::Agent { agent_id } if agent_id == agent.id )); assert_eq!( - f.sessions.session_for_agent(&agent.id), + f.sessions + .session_for_agent_in_project(project().id, &agent.id), Some(sid(777)), "the registry now holds the relaunched session" ); diff --git a/crates/application/tests/drain_with_readiness_lot1.rs b/crates/application/tests/drain_with_readiness_lot1.rs index 701eaad..2e4d2c1 100644 --- a/crates/application/tests/drain_with_readiness_lot1.rs +++ b/crates/application/tests/drain_with_readiness_lot1.rs @@ -23,7 +23,7 @@ use std::time::Duration; use async_trait::async_trait; use application::drain_with_readiness; -use domain::ids::AgentId; +use domain::ids::{AgentId, ProjectId, RuntimeAgentKey}; use domain::input::{AgentBusyState, InputMediator}; use domain::mailbox::{PendingReply, Ticket}; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; @@ -38,6 +38,10 @@ fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } +fn key(agent: AgentId) -> RuntimeAgentKey { + RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), agent) +} + // --------------------------------------------------------------------------- // Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs // --------------------------------------------------------------------------- @@ -121,17 +125,20 @@ impl RecordingMediator { } impl InputMediator for RecordingMediator { - fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply { // Jamais utilisé par drain_with_readiness ; un future qui ne résout pas. PendingReply::new(Box::pin(std::future::pending())) } - fn preempt(&self, agent: AgentId) { - self.calls.lock().unwrap().push((agent, "preempt")); + fn preempt(&self, agent: RuntimeAgentKey) { + self.calls.lock().unwrap().push((agent.agent_id, "preempt")); } - fn mark_idle(&self, agent: AgentId) { - self.calls.lock().unwrap().push((agent, "mark_idle")); + fn mark_idle(&self, agent: RuntimeAgentKey) { + self.calls + .lock() + .unwrap() + .push((agent.agent_id, "mark_idle")); } - fn busy_state(&self, _agent: AgentId) -> AgentBusyState { + fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState { AgentBusyState::Idle } } @@ -169,7 +176,7 @@ async fn final_only_stream_unblocks_queue_via_mark_idle() { let session = ScriptedSession::new(Script::Stream(vec![final_("done")])); let mediator = RecordingMediator::new(); - let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await; + let out = drain_with_readiness(&session, "tâche", None, &mediator, key(agent)).await; assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu"); assert_eq!( @@ -200,7 +207,7 @@ async fn intermediate_events_do_not_mark_idle_only_final_does() { ])); let mediator = RecordingMediator::new(); - let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await; assert_eq!(out, Ok("hello".to_owned())); assert_eq!( @@ -227,7 +234,7 @@ async fn heartbeats_alone_never_mark_idle_before_final() { ])); let mediator = RecordingMediator::new(); - let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await; assert_eq!(out, Ok("fini".to_owned())); assert_eq!(mediator.mark_idle_count(agent), 1); } @@ -242,7 +249,7 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() { let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")])); let mediator = RecordingMediator::new(); - let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await; assert!( matches!(out, Err(AgentSessionError::Io(_))), "flux épuisé sans Final ⇒ Io, obtenu {out:?}" @@ -263,7 +270,7 @@ async fn send_error_is_propagated_and_no_mark_idle() { ))); let mediator = RecordingMediator::new(); - let out = drain_with_readiness(&session, "x", None, &mediator, agent).await; + let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await; assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned()))); assert_eq!(mediator.mark_idle_count(agent), 0); } @@ -276,7 +283,7 @@ async fn mark_idle_targets_the_drained_agent_only() { let session = ScriptedSession::new(Script::Stream(vec![final_("ok")])); let mediator = RecordingMediator::new(); - let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await; + let _ = drain_with_readiness(&session, "x", None, &mediator, key(drained)).await; assert_eq!(mediator.mark_idle_count(drained), 1); assert_eq!( mediator.mark_idle_count(other), @@ -328,7 +335,7 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() { "x", Some(Duration::from_millis(20)), &mediator, - agent, + key(agent), ) .await; assert_eq!(out, Err(AgentSessionError::Timeout)); diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index 98e45a4..6b79378 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -18,7 +18,7 @@ use async_trait::async_trait; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::events::DomainEvent; use domain::ids::SkillId; -use domain::ids::{AgentId, NodeId, ProfileId, ProjectId}; +use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, RuntimeAgentKey}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle, @@ -647,6 +647,12 @@ fn pid(n: u128) -> ProfileId { fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } +fn project_id() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(1000)) +} +fn rkey(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new(project_id(), aid(n)) +} fn sid(n: u128) -> SessionId { SessionId::from_uuid(Uuid::from_u128(n)) } @@ -656,7 +662,7 @@ fn nid(n: u128) -> NodeId { fn project() -> Project { Project::new( - ProjectId::from_uuid(Uuid::from_u128(1000)), + project_id(), "demo", ProjectPath::new("/home/me/proj").unwrap(), RemoteRef::local(), @@ -1201,68 +1207,69 @@ impl CompletionBus { } impl AgentMailbox for TestMailbox { - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { let (tx, rx) = tokio::sync::oneshot::channel::(); self.queues .lock() .unwrap() - .entry(agent) + .entry(agent.agent_id) .or_default() .push_back((ticket, tx)); PendingReply::new(Box::pin(async move { rx.await.map_err(|_| MailboxError::Cancelled) })) } - fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> { + fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError> { let slot = { let mut q = self.queues.lock().unwrap(); let queue = q - .get_mut(&agent) + .get_mut(&agent.agent_id) .filter(|q| !q.is_empty()) - .ok_or(MailboxError::NoPendingRequest(agent))?; + .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?; queue.pop_front().expect("non-empty") }; self.completions - .push(agent, TestCompletion::Replied(result.clone())); + .push(agent.agent_id, TestCompletion::Replied(result.clone())); let _ = slot.1.send(TurnResolution::Replied(result)); Ok(()) } fn resolve_ticket( &self, - agent: AgentId, + agent: RuntimeAgentKey, ticket_id: TicketId, result: String, ) -> Result<(), MailboxError> { let slot = { let mut q = self.queues.lock().unwrap(); let queue = q - .get_mut(&agent) + .get_mut(&agent.agent_id) .filter(|q| !q.is_empty()) - .ok_or(MailboxError::NoPendingRequest(agent))?; + .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?; let pos = queue .iter() .position(|(t, _)| t.id == ticket_id) - .ok_or(MailboxError::NoPendingRequest(agent))?; + .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?; queue.remove(pos).expect("found position") }; self.completions - .push(agent, TestCompletion::Replied(result.clone())); + .push(agent.agent_id, TestCompletion::Replied(result.clone())); let _ = slot.1.send(TurnResolution::Replied(result)); Ok(()) } - fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { let mut q = self.queues.lock().unwrap(); - if let Some(queue) = q.get_mut(&agent) { + if let Some(queue) = q.get_mut(&agent.agent_id) { if queue.front().map(|(t, _)| t.id) == Some(ticket_id) { queue.pop_front(); - self.completions.push(agent, TestCompletion::Cancelled); + self.completions + .push(agent.agent_id, TestCompletion::Cancelled); } } } - fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) { + fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { // Mirror the production adapter: head-only, idempotent, fire-and-forget-safe. let mut q = self.queues.lock().unwrap(); - if let Some(queue) = q.get_mut(&agent) { + if let Some(queue) = q.get_mut(&agent.agent_id) { if queue.front().map(|(t, _)| t.id) != Some(ticket_id) { return; } @@ -1270,7 +1277,8 @@ impl AgentMailbox for TestMailbox { return; // receiver gone (human submit / timed-out caller): preserve head. } let (_, tx) = queue.pop_front().expect("head just matched"); - self.completions.push(agent, TestCompletion::NoReply); + self.completions + .push(agent.agent_id, TestCompletion::NoReply); let _ = tx.send(TurnResolution::ReturnedToPromptNoReply); } } @@ -1304,11 +1312,11 @@ impl TestMediator { } } impl InputMediator for TestMediator { - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; { let mut b = self.busy.lock().unwrap(); - let st = b.entry(agent).or_insert(AgentBusyState::Idle); + let st = b.entry(agent.agent_id).or_insert(AgentBusyState::Idle); if !st.is_busy() { *st = AgentBusyState::Busy { ticket: ticket_id, @@ -1316,7 +1324,7 @@ impl InputMediator for TestMediator { }; } } - if let Some(handle) = self.handles.lock().unwrap().get(&agent).cloned() { + if let Some(handle) = self.handles.lock().unwrap().get(&agent.agent_id).cloned() { let line = format!( "[IdeA · tâche de {} · ticket {}] {}\n", ticket.requester, ticket_id, ticket.task @@ -1325,26 +1333,26 @@ impl InputMediator for TestMediator { } self.mailbox.enqueue(agent, ticket) } - fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { - self.handles.lock().unwrap().insert(agent, handle); + fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) { + self.handles.lock().unwrap().insert(agent.agent_id, handle); } - fn delivers_turn(&self, agent: AgentId) -> bool { - self.handles.lock().unwrap().contains_key(&agent) + fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool { + self.handles.lock().unwrap().contains_key(&agent.agent_id) } - fn preempt(&self, agent: AgentId) { - self.preempts.lock().unwrap().push(agent); + fn preempt(&self, agent: RuntimeAgentKey) { + self.preempts.lock().unwrap().push(agent.agent_id); } - fn mark_idle(&self, agent: AgentId) { + fn mark_idle(&self, agent: RuntimeAgentKey) { self.busy .lock() .unwrap() - .insert(agent, AgentBusyState::Idle); + .insert(agent.agent_id, AgentBusyState::Idle); } - fn busy_state(&self, agent: AgentId) -> AgentBusyState { + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState { self.busy .lock() .unwrap() - .get(&agent) + .get(&agent.agent_id) .copied() .unwrap_or(AgentBusyState::Idle) } @@ -1354,29 +1362,38 @@ impl InputMediator for TestMediator { /// `infrastructure::InMemoryConversationRegistry`. #[derive(Default)] struct TestConversations { - by_pair: Mutex>, + by_pair: Mutex>, by_id: Mutex>, } impl TestConversations { fn new() -> Self { Self::default() } - fn key(a: ConversationParty, b: ConversationParty) -> (String, String) { + fn key( + project_id: ProjectId, + a: ConversationParty, + b: ConversationParty, + ) -> (ProjectId, String, String) { let s = |p: ConversationParty| match p { ConversationParty::User => "user".to_owned(), ConversationParty::Agent { agent_id } => agent_id.to_string(), }; let (ka, kb) = (s(a), s(b)); if ka <= kb { - (ka, kb) + (project_id, ka, kb) } else { - (kb, ka) + (project_id, kb, ka) } } } impl ConversationRegistry for TestConversations { - fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation { - let key = Self::key(a, b); + fn resolve( + &self, + project_id: ProjectId, + a: ConversationParty, + b: ConversationParty, + ) -> Conversation { + let key = Self::key(project_id, a, b); let mut pairs = self.by_pair.lock().unwrap(); if let Some(id) = pairs.get(&key).copied() { return self.by_id.lock().unwrap().get(&id).cloned().unwrap(); @@ -1696,7 +1713,7 @@ fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: Ses SessionKind::Agent { agent_id }, PtySize::new(24, 80).unwrap(), ); - sessions.insert(PtyHandle { session_id }, session); + sessions.insert_in_project(project_id(), PtyHandle { session_id }, session); } /// Waits (bounded) for a condition to hold, yielding between polls. @@ -2105,7 +2122,7 @@ async fn ask_target_returns_to_prompt_without_reply_is_a_typed_error() { // Simulate the grace-window completion (target back at prompt, no idea_reply). let ticket = fx.mailbox.ticket_ids(&aid(1))[0]; - fx.mailbox.complete_without_reply(aid(1), ticket); + fx.mailbox.complete_without_reply(rkey(1), ticket); let err = timeout(TEST_GUARD, ask) .await @@ -2135,7 +2152,7 @@ async fn ask_reply_wins_then_late_completion_is_noop() { .await .expect("reply ok"); // …then a late grace completion for the same ticket is a no-op. - fx.mailbox.complete_without_reply(aid(1), ticket); + fx.mailbox.complete_without_reply(rkey(1), ticket); let out = timeout(TEST_GUARD, ask) .await @@ -2263,7 +2280,7 @@ async fn ask_cancelled_turn_does_not_harvest() { // Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a // channel-closed error: no Response turn, hence no harvest. let ticket = fx.mailbox.ticket_ids(&aid(1))[0]; - fx.mailbox.cancel_head(aid(1), ticket); + fx.mailbox.cancel_head(rkey(1), ticket); let out = timeout(TEST_GUARD, ask) .await @@ -2287,7 +2304,7 @@ async fn idea_reply_marks_emitter_idle() { await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; // The delegated turn started ⇒ the target is Busy. assert!( - fx.mediator.busy_state(aid(1)).is_busy(), + fx.mediator.busy_state(rkey(1)).is_busy(), "target Busy while processing the delegated turn" ); @@ -2304,7 +2321,7 @@ async fn idea_reply_marks_emitter_idle() { // C5: the reply marked the emitter Idle (FIFO can advance) — no prompt pattern needed. assert_eq!( - fx.mediator.busy_state(aid(1)), + fx.mediator.busy_state(rkey(1)), AgentBusyState::Idle, "idea_reply is the explicit signal that frees the turn" ); @@ -2326,7 +2343,7 @@ async fn dropped_ask_future_frees_busy_target() { // Le tour a démarré : la cible est Busy, un ticket est en file. await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; assert!( - fx.mediator.busy_state(aid(1)).is_busy(), + fx.mediator.busy_state(rkey(1)).is_busy(), "cible Busy pendant le tour délégué" ); @@ -2335,9 +2352,9 @@ async fn dropped_ask_future_frees_busy_target() { let _ = ask.await; // récolte la JoinError(Cancelled), ignorée. // Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO. - await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await; + await_until(|| !fx.mediator.busy_state(rkey(1)).is_busy()).await; assert_eq!( - fx.mediator.busy_state(aid(1)), + fx.mediator.busy_state(rkey(1)), AgentBusyState::Idle, "futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)" ); @@ -2372,7 +2389,7 @@ async fn second_delegation_delivered_after_dropped_ask() { let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; assert!( - fx.mediator.busy_state(aid(1)).is_busy(), + fx.mediator.busy_state(rkey(1)).is_busy(), "le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée" ); @@ -2387,7 +2404,7 @@ async fn second_delegation_delivered_after_dropped_ask() { .expect("ask ok"); assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour")); assert_eq!( - fx.mediator.busy_state(aid(1)), + fx.mediator.busy_state(rkey(1)), AgentBusyState::Idle, "cible Idle après résolution du 2e tour" ); @@ -2406,12 +2423,12 @@ async fn cancelled_ask_marks_target_idle() { let svc = Arc::clone(&fx.service); let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; - assert!(fx.mediator.busy_state(aid(1)).is_busy()); + assert!(fx.mediator.busy_state(rkey(1)).is_busy()); // Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en // erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour. let t = fx.mailbox.ticket_ids(&aid(1))[0]; - fx.mailbox.cancel_head(aid(1), t); + fx.mailbox.cancel_head(rkey(1), t); let err = timeout(TEST_GUARD, ask) .await .expect("ask retourne vite sur canal fermé") @@ -2423,7 +2440,7 @@ async fn cancelled_ask_marks_target_idle() { ); // Le garde a ramené la cible Idle (la FIFO peut avancer). assert_eq!( - fx.mediator.busy_state(aid(1)), + fx.mediator.busy_state(rkey(1)), AgentBusyState::Idle, "branche erreur ⇒ cible Idle (garde RAII)" ); @@ -2449,7 +2466,7 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() { ); fx.mailbox - .resolve(aid(1), "launched reply".to_owned()) + .resolve(rkey(1), "launched reply".to_owned()) .expect("structured Final"); let out = timeout(TEST_GUARD, ask) .await @@ -2922,7 +2939,7 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() { // Débloque l'ask via le Final structured. fx.mailbox - .resolve(aid(1), "done".to_owned()) + .resolve(rkey(1), "done".to_owned()) .expect("structured completion ok"); timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); } @@ -2957,7 +2974,7 @@ async fn f1_ask_without_provider_writes_minimal_mcp_json() { ); fx.mailbox - .resolve(aid(1), "done".to_owned()) + .resolve(rkey(1), "done".to_owned()) .expect("structured completion ok"); timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); } @@ -2991,7 +3008,7 @@ async fn f2_ask_codex_target_is_invalid_no_launch() { }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; fx.mailbox - .resolve(aid(1), "codex ok".to_owned()) + .resolve(rkey(1), "codex ok".to_owned()) .expect("structured completion ok"); let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); assert_eq!(out.reply.as_deref(), Some("codex ok")); @@ -3015,7 +3032,7 @@ async fn f2_ask_claude_target_passes_guard() { let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; fx.mailbox - .resolve(aid(1), "ok claude".to_owned()) + .resolve(rkey(1), "ok claude".to_owned()) .expect("structured completion ok"); let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); assert_eq!(out.reply.as_deref(), Some("ok claude")); @@ -3153,12 +3170,15 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() { // The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B. let a_to_b = fx.conversations.resolve( + project_id(), ConversationParty::agent(aid(1)), ConversationParty::agent(aid(2)), ); - let user_b = fx - .conversations - .resolve(ConversationParty::User, ConversationParty::agent(aid(2))); + let user_b = fx.conversations.resolve( + project_id(), + ConversationParty::User, + ConversationParty::agent(aid(2)), + ); assert_ne!(a_to_b.id, user_b.id, "A↔B is a distinct thread from User↔B"); assert!( a_to_b.same_pair( @@ -3371,7 +3391,7 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() { // Retire the head ticket (drops its sender) ⇒ the awaiting ask sees a closed // channel and returns a typed error, the SAME cleanup the turn timeout performs. - fx.mailbox.cancel_head(aid(2), t); + fx.mailbox.cancel_head(rkey(2), t); let err = timeout(TEST_GUARD, ask) .await .expect("ask returns promptly once the channel closes") @@ -3388,7 +3408,8 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() { "queue freed after retirement" ); assert_eq!( - fx.sessions.session_for_agent(&aid(2)), + fx.sessions + .session_for_agent_in_project(project_id(), &aid(2)), Some(sid(802)), "target stays alive for the next turn" ); @@ -3527,7 +3548,7 @@ async fn submit_and_ask_share_one_fifo_per_agent() { // Unblock the delegation so the spawned task ends cleanly. fx.mailbox - .cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]); + .cancel_head(rkey(1), fx.mailbox.ticket_ids(&aid(1))[0]); let _ = timeout(TEST_GUARD, ask).await; } @@ -3821,7 +3842,10 @@ async fn run_ask_roundtrip( let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(&json)).await }); await_until(|| fx.mailbox.pending(&reply_from) == 1).await; fx.mailbox - .resolve(reply_from, result.to_owned()) + .resolve( + RuntimeAgentKey::new(project_id(), reply_from), + result.to_owned(), + ) .expect("structured completion ok"); timeout(TEST_GUARD, ask) .await @@ -3933,6 +3957,7 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() { let convs = TestConversations::new(); let expected = convs .resolve( + project_id(), ConversationParty::agent(a), ConversationParty::agent(aid(1)), ) @@ -4168,6 +4193,7 @@ struct NoopResumer; impl AgentResumer for NoopResumer { async fn resume( &self, + _project_id: ProjectId, _agent_id: AgentId, _node_id: NodeId, _conversation_id: Option, @@ -4291,7 +4317,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() { // Pré-condition : aucune session structurée vivante pour la cible (elle est froide). assert!( - fx.structured.session_for_agent(&aid(1)).is_none(), + fx.structured + .session_for_agent_in_project(project_id(), &aid(1)) + .is_none(), "cible structurée froide : aucune session avant l'ask" ); @@ -4315,7 +4343,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() { ); // La session est désormais vivante dans le registre partagé (insérée par le launcher). assert!( - fx.structured.session_for_agent(&aid(1)).is_some(), + fx.structured + .session_for_agent_in_project(project_id(), &aid(1)) + .is_some(), "la session auto-lancée est enregistrée dans StructuredSessions" ); // Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal). @@ -4375,11 +4405,15 @@ async fn ask_structured_target_live_as_pty_keeps_visible_terminal() { assert_eq!(fx.factory.start_count(), 1, "headless session started"); assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped"); assert!( - fx.structured.session_for_agent(&aid(1)).is_some(), + fx.structured + .session_for_agent_in_project(project_id(), &aid(1)) + .is_some(), "target now has a structured session" ); assert!( - fx.sessions.session_for_agent(&aid(1)).is_some(), + fx.sessions + .session_for_agent_in_project(project_id(), &aid(1)) + .is_some(), "target still has its visible PTY session" ); } @@ -4392,7 +4426,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); let resets_at_ms = 9_999_999; - fx.structured.insert( + fx.structured.insert_in_project( + project_id(), Arc::new(RateLimitedSession { id: sid(9700), conversation_id: Some("engine-target".to_owned()), @@ -4443,7 +4478,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() { async fn ask_structured_rate_limit_without_conversation_uses_human_fallback_no_resume() { let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); - fx.structured.insert( + fx.structured.insert_in_project( + project_id(), Arc::new(RateLimitedSession { id: sid(9701), conversation_id: None, diff --git a/crates/application/tests/profile_usecases.rs b/crates/application/tests/profile_usecases.rs index 7bb154a..6b242be 100644 --- a/crates/application/tests/profile_usecases.rs +++ b/crates/application/tests/profile_usecases.rs @@ -418,7 +418,10 @@ async fn save_then_list_then_delete() { let store = FakeProfileStore::default(); let save = SaveProfile::new(Arc::new(store.clone())); let list = ListProfiles::new(Arc::new(store.clone())); - let delete = DeleteProfile::new(Arc::new(store.clone()), Arc::new(FakeSecretStore::default())); + let delete = DeleteProfile::new( + Arc::new(store.clone()), + Arc::new(FakeSecretStore::default()), + ); let p = profile(1, "Claude", "claude"); let saved = save @@ -532,7 +535,10 @@ async fn save_opencode_provider_profile_drops_stale_local_backend() { .await .unwrap(); - assert!(out.profile.opencode.is_none(), "stale local backend dropped"); + assert!( + out.profile.opencode.is_none(), + "stale local backend dropped" + ); assert_eq!( out.profile.opencode_provider.as_ref().unwrap().provider_id, "anthropic" @@ -571,7 +577,13 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() { }) .await .unwrap(); - let secret_ref = saved.profile.opencode_provider.as_ref().unwrap().api_key_ref.clone(); + let secret_ref = saved + .profile + .opencode_provider + .as_ref() + .unwrap() + .api_key_ref + .clone(); assert_eq!( secrets.get(&secret_ref).await.unwrap(), Some("sk-live-to-be-purged".to_owned()) @@ -707,8 +719,14 @@ async fn clone_opencode_profile_accepts_a_cloud_provider_seed() { ) .unwrap(), ); - assert!(cloud_seed.opencode.is_none(), "precondition: cloud seed has no local backend"); - assert!(cloud_seed.opencode_provider.is_some(), "precondition: cloud seed has a provider"); + assert!( + cloud_seed.opencode.is_none(), + "precondition: cloud seed has no local backend" + ); + assert!( + cloud_seed.opencode_provider.is_some(), + "precondition: cloud seed has a provider" + ); SaveProfile::new(Arc::new(store.clone())) .execute(SaveProfileInput { @@ -787,12 +805,13 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n None, ) .unwrap(); - assert_eq!(squatter.structured_adapter, None, "precondition: not an OpenCode profile"); + assert_eq!( + squatter.structured_adapter, None, + "precondition: not an OpenCode profile" + ); SaveProfile::new(Arc::new(store.clone())) - .execute(SaveProfileInput { - profile: squatter, - }) + .execute(SaveProfileInput { profile: squatter }) .await .unwrap(); diff --git a/crates/application/tests/session_limit_service.rs b/crates/application/tests/session_limit_service.rs index 3115630..ff08df2 100644 --- a/crates/application/tests/session_limit_service.rs +++ b/crates/application/tests/session_limit_service.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; use application::{AgentResumer, AppError, SessionLimitService, RESUME_PROMPT}; -use domain::ids::{AgentId, NodeId, ScheduleId}; +use domain::ids::{AgentId, NodeId, ProjectId, ScheduleId}; use domain::ports::{Clock, EventBus, EventStream, ScheduledTask, Scheduler}; use domain::DomainEvent; use uuid::Uuid; @@ -24,6 +24,9 @@ fn aid(n: u128) -> AgentId { fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } +fn pid(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} // --------------------------------------------------------------------------- // Fakes des ports @@ -128,6 +131,7 @@ impl FakeResumer { impl AgentResumer for FakeResumer { async fn resume( &self, + _project_id: ProjectId, agent_id: AgentId, node_id: NodeId, conversation_id: Option, @@ -185,8 +189,13 @@ const NOW: i64 = 1_700_000_000_000; fn on_rate_limited_future_arms_and_emits_in_order() { let env = env_at(NOW); let reset = NOW + 60_000; - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset)); + env.service.on_rate_limited( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(reset), + ); // Exactement un arm, avec la bonne échéance et la bonne tâche. let armed = env.scheduler.armed(); @@ -195,6 +204,7 @@ fn on_rate_limited_future_arms_and_emits_in_order() { assert_eq!( armed[0].1, ScheduledTask::ResumeAgent { + project_id: pid(1), agent_id: aid(1), node_id: nid(2), conversation_id: Some("conv-1".to_owned()), @@ -225,7 +235,7 @@ fn on_rate_limited_past_reset_clamps_fire_at_to_now() { let env = env_at(NOW); let past = NOW - 60_000; env.service - .on_rate_limited(aid(1), nid(2), None, Some(past)); + .on_rate_limited(pid(1), aid(1), nid(2), None, Some(past)); let armed = env.scheduler.armed(); assert_eq!(armed.len(), 1); @@ -252,7 +262,7 @@ fn on_rate_limited_past_reset_clamps_fire_at_to_now() { fn on_rate_limited_without_reset_is_human_fallback_no_arm() { let env = env_at(NOW); env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None); + .on_rate_limited(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), None); assert!( env.scheduler.armed().is_empty(), @@ -282,10 +292,20 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() { let env = env_at(NOW); let reset1 = NOW + 60_000; let reset2 = NOW + 120_000; - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset1)); - env.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset2)); + env.service.on_rate_limited( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(reset1), + ); + env.service.on_rate_limited( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(reset2), + ); // Deux arms (un par signal), ids distincts. let issued = env.scheduler.issued(); @@ -326,6 +346,7 @@ 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( + pid(1), aid(1), nid(2), Some("conv-1".to_owned()), @@ -333,6 +354,7 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() { ); let task = ScheduledTask::ResumeAgent { + project_id: pid(1), agent_id: aid(1), node_id: nid(2), conversation_id: Some("conv-1".to_owned()), @@ -373,6 +395,7 @@ async fn execute_resume_propagates_error_without_emitting_resumed() { env.resumer.set_fail(true); let task = ScheduledTask::ResumeAgent { + project_id: pid(1), agent_id: aid(1), node_id: nid(2), conversation_id: None, @@ -406,6 +429,7 @@ async fn execute_resume_propagates_error_without_emitting_resumed() { fn cancel_resume_after_arm_returns_true_and_emits_cancelled() { let env = env_at(NOW); env.service.on_rate_limited( + pid(1), aid(1), nid(2), Some("conv-1".to_owned()), @@ -448,6 +472,7 @@ fn cancel_resume_without_arm_is_false_no_event() { fn cancel_resume_when_scheduler_already_fired_is_false_no_event() { let env = env_at(NOW); env.service.on_rate_limited( + pid(1), aid(1), nid(2), Some("conv-1".to_owned()), @@ -484,7 +509,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() { let env = env_at(NOW); let reset = NOW + 90_000; env.service - .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset); + .confirm_human_resume(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), reset); // Exactement un arm, bonne échéance, bonne tâche. let armed = env.scheduler.armed(); @@ -493,6 +518,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() { assert_eq!( armed[0].1, ScheduledTask::ResumeAgent { + project_id: pid(1), agent_id: aid(1), node_id: nid(2), conversation_id: Some("conv-1".to_owned()), @@ -524,7 +550,8 @@ 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(pid(1), aid(1), nid(2), None, past); let armed = env.scheduler.armed(); assert_eq!(armed.len(), 1); @@ -554,13 +581,19 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() { fn confirm_human_resume_after_auto_dedups_single_active_arm() { let env = env_at(NOW); env.service.on_rate_limited( + pid(1), 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); + env.service.confirm_human_resume( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + NOW + 120_000, + ); let issued = env.scheduler.issued(); assert_eq!( @@ -600,9 +633,15 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() { #[test] 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.confirm_human_resume( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + NOW + 60_000, + ); env.service.on_rate_limited( + pid(1), aid(1), nid(2), Some("conv-1".to_owned()), @@ -644,8 +683,13 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() { #[test] fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() { let env = env_at(NOW); - env.service - .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); + env.service.confirm_human_resume( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + NOW + 60_000, + ); let issued = env.scheduler.issued(); assert!( @@ -670,13 +714,18 @@ fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() { let reset = NOW + 60_000; let auto = env_at(NOW); - auto.service - .on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset)); + auto.service.on_rate_limited( + pid(1), + aid(1), + nid(2), + Some("conv-1".to_owned()), + Some(reset), + ); let human = env_at(NOW); human .service - .confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset); + .confirm_human_resume(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), reset); // Même séquence d'events. assert_eq!( diff --git a/crates/application/tests/session_limit_t4.rs b/crates/application/tests/session_limit_t4.rs index 2a225b6..6836cf1 100644 --- a/crates/application/tests/session_limit_t4.rs +++ b/crates/application/tests/session_limit_t4.rs @@ -13,7 +13,7 @@ use std::sync::Mutex; use async_trait::async_trait; use application::{drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome}; -use domain::ids::AgentId; +use domain::ids::{AgentId, ProjectId, RuntimeAgentKey}; use domain::input::{AgentBusyState, InputMediator}; use domain::mailbox::{PendingReply, Ticket}; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; @@ -24,6 +24,10 @@ fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } +fn key(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), aid(n)) +} + // --------------------------------------------------------------------------- // Fake AgentSession : `send` rejoue une liste fixe d'événements. // --------------------------------------------------------------------------- @@ -57,17 +61,17 @@ struct RecordingMediator { calls: Mutex>, } impl InputMediator for RecordingMediator { - fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply { PendingReply::new(Box::pin(std::future::pending())) } - fn preempt(&self, _agent: AgentId) {} - fn mark_idle(&self, _agent: AgentId) { + fn preempt(&self, _agent: RuntimeAgentKey) {} + fn mark_idle(&self, _agent: RuntimeAgentKey) { self.calls.lock().unwrap().push("idle"); } - fn mark_alive(&self, _agent: AgentId) { + fn mark_alive(&self, _agent: RuntimeAgentKey) { self.calls.lock().unwrap().push("alive"); } - fn busy_state(&self, _agent: AgentId) -> AgentBusyState { + fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState { AgentBusyState::Idle } } @@ -86,7 +90,7 @@ async fn outcome_rate_limited_some_without_final_is_graceful() { }], }; let mediator = RecordingMediator::default(); - let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1)) + let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1)) .await .expect("un tour limité est une fin gracieuse, pas une erreur"); assert_eq!( @@ -109,7 +113,7 @@ async fn outcome_rate_limited_none_without_final_is_graceful() { events: vec![ReplyEvent::RateLimited { resets_at_ms: None }], }; let mediator = RecordingMediator::default(); - let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1)) + let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1)) .await .expect("fin gracieuse"); assert_eq!(out, TurnOutcome::RateLimited { resets_at_ms: None }); @@ -131,7 +135,7 @@ async fn outcome_rate_limited_then_final_is_completed() { ], }; let mediator = RecordingMediator::default(); - let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1)) + let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1)) .await .expect("ok"); assert_eq!(out, TurnOutcome::Completed("fini".to_owned())); @@ -147,7 +151,7 @@ async fn outcome_truncated_stream_without_final_or_ratelimit_is_io_error() { events: vec![ReplyEvent::TextDelta { text: "a".into() }], }; let mediator = RecordingMediator::default(); - let err = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1)) + let err = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1)) .await .expect_err("flux tronqué sans limite ⇒ erreur"); assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}"); @@ -167,7 +171,7 @@ async fn drain_with_readiness_rate_limited_is_io_error() { }], }; let mediator = RecordingMediator::default(); - let err = drain_with_readiness(&session, "go", None, &mediator, aid(1)) + let err = drain_with_readiness(&session, "go", None, &mediator, key(1)) .await .expect_err("limite ⇒ Io sur la signature historique"); assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}"); @@ -197,7 +201,7 @@ async fn drain_with_readiness_nominal_still_completes() { ], }; let mediator = RecordingMediator::default(); - let content = drain_with_readiness(&session, "go", None, &mediator, aid(1)) + let content = drain_with_readiness(&session, "go", None, &mediator, key(1)) .await .expect("ok"); assert_eq!(content, "fini"); diff --git a/crates/application/tests/snapshot_running_agents.rs b/crates/application/tests/snapshot_running_agents.rs index af567f5..19eb826 100644 --- a/crates/application/tests/snapshot_running_agents.rs +++ b/crates/application/tests/snapshot_running_agents.rs @@ -147,7 +147,7 @@ impl FakeLive { } impl LiveAgentRegistry for FakeLive { - fn is_agent_live(&self, agent_id: &AgentId) -> bool { + fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool { self.agents.lock().unwrap().contains(agent_id) } fn is_node_live(&self, node_id: &NodeId) -> bool { diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index 7336ff6..a2d79ad 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -555,7 +555,7 @@ impl AgentSessionFactory for FakeFactory { // --------------------------------------------------------------------------- use application::ProviderSessionProvider; -use domain::{ConversationId, ProviderSessionStore}; +use domain::{ConversationId, ConversationParty, ProviderSessionStore}; /// In-memory [`ProviderSessionStore`] for the P8b launch tests: a /// `(conversation, provider_id) → resumable_id` map, observable after the launch. @@ -659,9 +659,13 @@ fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } +fn project_id() -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(1000)) +} + fn project() -> Project { Project::new( - ProjectId::from_uuid(Uuid::from_u128(1000)), + project_id(), "demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::local(), @@ -734,7 +738,7 @@ fn seed_live_pty_session( size, ); session.status = domain::SessionStatus::Running; - sessions.insert(PtyHandle { session_id }, session); + sessions.insert_in_project(project_id(), PtyHandle { session_id }, session); } // --------------------------------------------------------------------------- @@ -815,12 +819,19 @@ async fn structured_launch_starts_session_registers_no_pty_spawn() { // La session est enregistrée dans le registre structuré, retrouvable par agent. let registered = f .structured - .session_for_agent(&f.agent.id) + .session_for_agent_in_project(project_id(), &f.agent.id) .expect("structured session registered"); assert_eq!(registered.id(), sid(500), "session id is the factory's"); - assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(3))); + assert_eq!( + f.structured + .node_for_agent_in_project(project_id(), &f.agent.id), + Some(nid(3)) + ); // Rien côté registre PTY. - assert!(f.sessions.session_for_agent(&f.agent.id).is_none()); + assert!(f + .sessions + .session_for_agent_in_project(project_id(), &f.agent.id) + .is_none()); // AgentLaunched publié avec l'id de session structurée. assert_eq!( @@ -845,11 +856,15 @@ async fn structured_launch_starts_session_registers_no_pty_spawn() { SessionKind::Agent { agent_id } if agent_id == f.agent.id )); // P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id - // moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(User, agent)` - // dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`). + // moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(project, User, agent)`. + let expected_pair = ConversationId::for_project_pair( + project_id(), + ConversationParty::User, + ConversationParty::agent(f.agent.id), + ); assert_eq!( out.assigned_conversation_id.as_deref(), - Some("00000000-0000-0000-0000-000000000001"), + Some(expected_pair.to_string().as_str()), "cell carries the IdeA pair id (pivot logique), not the engine resumable" ); // L'id de session MOTEUR (resumable provider) part dans le cache séparé. @@ -882,8 +897,15 @@ async fn non_structured_profile_takes_pty_path_unchanged() { ); // Session côté registre PTY, rien côté structuré. - assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777))); - assert!(f.structured.session_for_agent(&f.agent.id).is_none()); + assert_eq!( + f.sessions + .session_for_agent_in_project(project_id(), &f.agent.id), + Some(sid(777)) + ); + assert!(f + .structured + .session_for_agent_in_project(project_id(), &f.agent.id) + .is_none()); // output.structured = None ; session PTY classique. assert!( @@ -937,7 +959,8 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() { "still a single live structured session" ); assert_eq!( - f.structured.node_for_agent(&f.agent.id), + f.structured + .node_for_agent_in_project(project_id(), &f.agent.id), Some(host), "session stays pinned on its host node A" ); @@ -964,7 +987,11 @@ async fn structured_relaunch_same_node_rebinds_no_second_start() { assert_eq!(f.factory.start_count(), 1, "no second factory.start"); assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); assert_eq!(f.structured.len(), 1, "single live structured session"); - assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(host)); + assert_eq!( + f.structured + .node_for_agent_in_project(project_id(), &f.agent.id), + Some(host) + ); let desc = out.structured.expect("descriptor on rebind"); assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.node_id, host); @@ -1002,7 +1029,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() { 1, "still a single live structured session" ); - assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target)); + assert_eq!( + f.structured + .node_for_agent_in_project(project_id(), &f.agent.id), + Some(target) + ); let desc = out.structured.expect("descriptor on rebind"); assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.node_id, target); @@ -1130,7 +1161,8 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { .start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None) .await .expect("seed structured session"); - f.structured.insert(session, agent.id, host); + f.structured + .insert_in_project(project_id(), session, agent.id, host); } // La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on // comptera les start APRÈS, donc on mémorise la base. @@ -1182,8 +1214,16 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { relaunched.node_id, host, "relaunch reopens in the same cell" ); - assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601))); - assert_eq!(f.structured.node_for_agent(&agent.id), Some(host)); + assert_eq!( + f.structured + .session_id_for_agent_in_project(project_id(), &agent.id), + Some(sid(601)) + ); + assert_eq!( + f.structured + .node_for_agent_in_project(project_id(), &agent.id), + Some(host) + ); assert_eq!( f.structured.len(), 1, @@ -1230,8 +1270,15 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() { ); assert_eq!(relaunched.id, sid(777)); // The relaunched session lives in the PTY registry, not the structured one. - assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777))); - assert!(f.structured.session_for_agent(&agent.id).is_none()); + assert_eq!( + f.sessions + .session_for_agent_in_project(project_id(), &agent.id), + Some(sid(777)) + ); + assert!(f + .structured + .session_for_agent_in_project(project_id(), &agent.id) + .is_none()); assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2))); } diff --git a/crates/application/tests/structured_registry_d1.rs b/crates/application/tests/structured_registry_d1.rs index 4f87679..717d046 100644 --- a/crates/application/tests/structured_registry_d1.rs +++ b/crates/application/tests/structured_registry_d1.rs @@ -22,7 +22,9 @@ use async_trait::async_trait; use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions}; use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream}; -use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession}; +use domain::{ + AgentId, NodeId, ProjectId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession, +}; use uuid::Uuid; // --- petits constructeurs déterministes ------------------------------------ @@ -33,6 +35,9 @@ fn sid(n: u128) -> SessionId { fn aid(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } +fn pid(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} fn nid(n: u128) -> NodeId { NodeId::from_uuid(Uuid::from_u128(n)) } @@ -129,7 +134,7 @@ fn structured_meta_for_session_resolves_agent_node_and_conversation() { assert_eq!( reg.meta_for_session(&s), - Some((a, n, Some("conv-live".to_owned()))) + Some((pid(0), a, n, Some("conv-live".to_owned()))) ); // Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte). @@ -158,11 +163,11 @@ fn structured_one_live_session_per_agent_invariant() { // L'agent n'a pas de session vivante avant insertion. let reg = StructuredSessions::new(); let a = aid(10); - assert!(!reg.is_agent_live(&a)); + assert!(!reg.is_agent_live(pid(0), &a)); assert!(reg.session_for_agent(&a).is_none()); reg.insert(fake(sid(1)), a, nid(100)); - assert!(reg.is_agent_live(&a)); + assert!(reg.is_agent_live(pid(0), &a)); // `session_for_agent` est non ambigu : il rend LA session de l'agent. let resolved = reg.session_for_agent(&a).unwrap().id(); @@ -200,14 +205,14 @@ fn structured_live_agent_registry_impl() { let a = aid(10); let n = nid(100); - assert!(!reg.is_agent_live(&a)); + assert!(!reg.is_agent_live(pid(0), &a)); assert!(!reg.is_node_live(&n)); reg.insert(fake(sid(1)), a, n); - assert!(reg.is_agent_live(&a)); + assert!(reg.is_agent_live(pid(0), &a)); assert!(reg.is_node_live(&n)); - assert!(!reg.is_agent_live(&aid(999))); + assert!(!reg.is_agent_live(pid(0), &aid(999))); assert!(!reg.is_node_live(&nid(999))); // is_node_live suit le rebind (la cellule vivante change). @@ -233,6 +238,17 @@ fn structured_sessions_snapshot_for_global_shutdown() { /// Insère un agent PTY dans `TerminalSessions`. fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) { + insert_pty_in_project(pty, pid(0), s, a, n); +} + +/// Insère un agent PTY dans `TerminalSessions` pour un projet explicite. +fn insert_pty_in_project( + pty: &TerminalSessions, + project_id: ProjectId, + s: SessionId, + a: AgentId, + n: NodeId, +) { let session = TerminalSession::starting( s, n, @@ -240,7 +256,7 @@ fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) { SessionKind::Agent { agent_id: a }, PtySize::new(24, 80).unwrap(), ); - pty.insert(PtyHandle { session_id: s }, session); + pty.insert_in_project(project_id, PtyHandle { session_id: s }, session); } #[test] @@ -252,10 +268,10 @@ fn aggregator_agent_live_via_structured_only() { let a = aid(10); structured.insert(fake(sid(1)), a, nid(100)); - assert!(agg.is_agent_live(&a), "live in structured ⇒ true"); + assert!(agg.is_agent_live(pid(0), &a), "live in structured ⇒ true"); assert!(agg.is_node_live(&nid(100))); - assert_eq!(agg.session_id_for_agent(&a), Some(sid(1))); - assert_eq!(agg.node_for_agent(&a), Some(nid(100))); + assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(1))); + assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(100))); } #[test] @@ -267,10 +283,10 @@ fn aggregator_agent_live_via_pty_only() { let a = aid(20); insert_pty(&pty, sid(2), a, nid(200)); - assert!(agg.is_agent_live(&a), "live in PTY ⇒ true"); + assert!(agg.is_agent_live(pid(0), &a), "live in PTY ⇒ true"); assert!(agg.is_node_live(&nid(200))); - assert_eq!(agg.session_id_for_agent(&a), Some(sid(2))); - assert_eq!(agg.node_for_agent(&a), Some(nid(200))); + assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(2))); + assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(200))); } #[test] @@ -280,11 +296,11 @@ fn aggregator_agent_absent_from_both_is_not_live() { let agg = LiveSessions::new(pty, structured); let a = aid(30); - assert!(!agg.is_agent_live(&a), "absent from both ⇒ false"); + assert!(!agg.is_agent_live(pid(0), &a), "absent from both ⇒ false"); assert!(!agg.is_node_live(&nid(300))); - assert!(agg.session_id_for_agent(&a).is_none()); - assert!(agg.node_for_agent(&a).is_none()); - assert!(agg.live_agents().is_empty()); + assert!(agg.session_id_for_agent(pid(0), &a).is_none()); + assert!(agg.node_for_agent(pid(0), &a).is_none()); + assert!(agg.live_agents(pid(0)).is_empty()); } #[test] @@ -296,7 +312,7 @@ fn aggregator_live_agents_concatenates_pty_then_structured() { insert_pty(&pty, sid(1), aid(10), nid(100)); structured.insert(fake(sid(2)), aid(20), nid(200)); - let all = agg.live_agents(); + let all = agg.live_agents(pid(0)); assert_eq!(all.len(), 2, "both registries contribute"); // PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur). assert_eq!(all[0], (aid(10), nid(100), sid(1))); @@ -317,14 +333,37 @@ fn aggregator_resolution_prefers_pty_then_falls_back_to_structured() { structured.insert(fake(sid(2)), struct_agent, nid(200)); // Agent PTY : résolu par le registre PTY. - assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1))); - assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100))); + assert_eq!(agg.session_id_for_agent(pid(0), &pty_agent), Some(sid(1))); + assert_eq!(agg.node_for_agent(pid(0), &pty_agent), Some(nid(100))); // Agent structuré : fallback sur le registre structuré. - assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2))); - assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200))); + assert_eq!( + agg.session_id_for_agent(pid(0), &struct_agent), + Some(sid(2)) + ); + assert_eq!(agg.node_for_agent(pid(0), &struct_agent), Some(nid(200))); // is_node_live : OR sur les deux. assert!(agg.is_node_live(&nid(100))); assert!(agg.is_node_live(&nid(200))); assert!(!agg.is_node_live(&nid(999))); } + +#[test] +fn same_agent_id_in_distinct_projects_has_isolated_live_sessions() { + let pty = Arc::new(TerminalSessions::new()); + let structured = Arc::new(StructuredSessions::new()); + let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured)); + let a = aid(10); + + insert_pty_in_project(&pty, pid(1), sid(1), a, nid(100)); + structured.insert_in_project(pid(2), fake(sid(2)), a, nid(200)); + + assert!(agg.is_agent_live(pid(1), &a)); + assert!(agg.is_agent_live(pid(2), &a)); + assert_eq!(agg.session_id_for_agent(pid(1), &a), Some(sid(1))); + assert_eq!(agg.node_for_agent(pid(1), &a), Some(nid(100))); + assert_eq!(agg.session_id_for_agent(pid(2), &a), Some(sid(2))); + assert_eq!(agg.node_for_agent(pid(2), &a), Some(nid(200))); + assert_eq!(agg.live_agents(pid(1)), vec![(a, nid(100), sid(1))]); + assert_eq!(agg.live_agents(pid(2)), vec![(a, nid(200), sid(2))]); +} diff --git a/crates/application/tests/workstate.rs b/crates/application/tests/workstate.rs index 5bf09b4..cb04393 100644 --- a/crates/application/tests/workstate.rs +++ b/crates/application/tests/workstate.rs @@ -25,7 +25,8 @@ use domain::{ BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource, ManifestEntry, MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, - RemoteRef, SessionId, SessionKind, TaskId, TerminalSession, TicketId, TurnId, TurnRole, + RemoteRef, RuntimeAgentKey, SessionId, SessionKind, TaskId, TerminalSession, TicketId, TurnId, + TurnRole, }; use uuid::Uuid; @@ -129,21 +130,21 @@ impl FakeInput { } impl InputMediator for FakeInput { - fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { + fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply { let fut: Pin> + Send>> = Box::pin(async { Err(MailboxError::Cancelled) }); PendingReply::new(fut) } - fn preempt(&self, _agent: AgentId) {} + fn preempt(&self, _agent: RuntimeAgentKey) {} - fn mark_idle(&self, _agent: AgentId) {} + fn mark_idle(&self, _agent: RuntimeAgentKey) {} - fn busy_state(&self, agent: AgentId) -> AgentBusyState { + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState { self.busy .lock() .unwrap() - .get(&agent) + .get(&agent.agent_id) .copied() .unwrap_or(AgentBusyState::Idle) } @@ -261,11 +262,11 @@ impl FakeQueue { } impl AgentQueueSnapshot for FakeQueue { - fn queue_for(&self, agent: AgentId) -> Vec { + fn queue_for(&self, agent: RuntimeAgentKey) -> Vec { self.queues .lock() .unwrap() - .get(&agent) + .get(&agent.agent_id) .cloned() .unwrap_or_default() } @@ -446,7 +447,8 @@ fn insert_pty( agent_id: AgentId, node_id: NodeId, ) { - sessions.insert( + sessions.insert_in_project( + project().id, PtyHandle { session_id }, TerminalSession::starting( session_id, @@ -685,7 +687,8 @@ async fn workstate_attaches_live_pty_session_to_manifest_agent() { async fn workstate_attaches_live_structured_session_to_manifest_agent() { let a = agent(10, "alpha"); let f = fixture(std::slice::from_ref(&a)); - f.structured.insert(fake_session(sid(2)), a.id, nid(200)); + f.structured + .insert_in_project(f.project.id, fake_session(sid(2)), a.id, nid(200)); let out = f .usecase diff --git a/crates/application/tests/workstate_actions.rs b/crates/application/tests/workstate_actions.rs index b9ab999..aaf1570 100644 --- a/crates/application/tests/workstate_actions.rs +++ b/crates/application/tests/workstate_actions.rs @@ -156,7 +156,8 @@ fn insert_pty( agent_id: AgentId, node_id: NodeId, ) { - sessions.insert( + sessions.insert_in_project( + project().id, PtyHandle { session_id }, TerminalSession::starting( session_id, @@ -175,7 +176,8 @@ fn insert_structured( node_id: NodeId, ) -> Arc { let flag = Arc::new(AtomicBool::new(false)); - sessions.insert( + sessions.insert_in_project( + project().id, Arc::new(FakeSession { id: session_id, shutdown_called: Arc::clone(&flag), @@ -210,8 +212,14 @@ fn attach_pty_rebinds_node_without_changing_session() { assert_eq!(out.node_id, nid(200), "view rebound to the new node"); assert_eq!(out.kind, LiveSessionKind::Pty); // The registry reflects the new host node, same session. - assert_eq!(f.pty.node_for_agent(&a), Some(nid(200))); - assert_eq!(f.pty.session_for_agent(&a), Some(sid(1))); + assert_eq!( + f.pty.node_for_agent_in_project(project().id, &a), + Some(nid(200)) + ); + assert_eq!( + f.pty.session_for_agent_in_project(project().id, &a), + Some(sid(1)) + ); } #[test] @@ -232,7 +240,10 @@ fn attach_structured_rebinds_node() { assert_eq!(out.session_id, sid(2)); assert_eq!(out.node_id, nid(300)); assert_eq!(out.kind, LiveSessionKind::Structured); - assert_eq!(f.structured.node_for_agent(&a), Some(nid(300))); + assert_eq!( + f.structured.node_for_agent_in_project(project().id, &a), + Some(nid(300)) + ); } #[test] @@ -309,7 +320,7 @@ async fn stop_pty_kills_and_removes_session() { // Delegated to the close primitive: process killed and registry emptied. assert_eq!(f.pty_port.kills(), vec![sid(1)]); assert!(f.pty.is_empty(), "live session removed from the registry"); - assert_eq!(f.pty.session_for_agent(&a), None); + assert_eq!(f.pty.session_for_agent_in_project(project().id, &a), None); } #[tokio::test] @@ -331,7 +342,11 @@ async fn stop_structured_shuts_down_and_removes_session() { assert_eq!(out.kind, LiveSessionKind::Structured); assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called"); assert!(f.structured.is_empty(), "live session removed"); - assert_eq!(f.structured.session_id_for_agent(&a), None); + assert_eq!( + f.structured + .session_id_for_agent_in_project(project().id, &a), + None + ); // No PTY was touched. assert!(f.pty_port.kills().is_empty()); } diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 56fc0c2..351f750 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -29,22 +29,21 @@ use application::{ ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, - LiveAgentRegistry, LiveSessions, - LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, - McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, - OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, - PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, - ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, - ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, - ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, - RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, - ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, - RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, - RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, - SaveProfile, SessionLimitService, - SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, - SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, + LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider, + LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow, + MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant, + OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry, + ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, + ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext, + ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState, + ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider, + ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, + ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, + ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog, + SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile, + SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, + SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, + StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, @@ -85,8 +84,7 @@ use infrastructure::{ FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore, - FsWindowStateStore, - Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, @@ -696,7 +694,10 @@ impl WakeSessionProvider for AppWakeSessionProvider { project: &Project, agent: AgentId, ) -> Result, WakeError> { - if let Some(session) = self.structured_sessions.session_for_agent(&agent) { + if let Some(session) = self + .structured_sessions + .session_for_agent_in_project(project.id, &agent) + { return Ok(session); } @@ -715,7 +716,7 @@ impl WakeSessionProvider for AppWakeSessionProvider { .map_err(|err| WakeError::Session(err.to_string()))?; self.structured_sessions - .session_for_agent(&agent) + .session_for_agent_in_project(project.id, &agent) .ok_or_else(|| { WakeError::Session(format!( "agent {agent} has no structured session after background wake launch" @@ -745,11 +746,11 @@ impl application::ProviderSessionProvider for AppProviderSessionProvider { /// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5). /// -/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le -/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige. -/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce -/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour -/// recomposer un lancement complet. +/// [`AgentResumer::resume`] ne porte pas le `Project` complet ni la taille de la +/// cellule, alors que [`LaunchAgentInput`] les exige. La commande `launch_agent` +/// (seul endroit où ces faits sont en main) alimente ce contexte par clé runtime +/// `(project_id, agent_id)` ; [`AppAgentResumer`] le relit à l'échéance pour +/// recomposer un lancement complet sans collision entre projets. #[derive(Clone)] pub struct ResumeContext { /// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`). @@ -760,10 +761,10 @@ pub struct ResumeContext { pub cols: u16, } -/// Registre partagé `agent_id → ResumeContext` (composition root ↔ commande +/// Registre partagé `(project_id, agent_id) → ResumeContext` (composition root ↔ commande /// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé /// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement. -pub type ResumeContexts = Arc>>; +pub type ResumeContexts = Arc>>; /// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de /// lancement existant ([`LaunchAgent`]). @@ -789,6 +790,7 @@ struct AppAgentResumer { impl AgentResumer for AppAgentResumer { async fn resume( &self, + project_id: ProjectId, agent_id: AgentId, node_id: domain::NodeId, conversation_id: Option, @@ -796,11 +798,10 @@ impl AgentResumer for AppAgentResumer { ) -> Result<(), AppError> { // Repli propre (jamais de panique) : sans contexte de relance connu, on ne // reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié. - let ctx = self - .contexts - .lock() - .ok() - .and_then(|m| m.get(&agent_id).cloned()); + let ctx = self.contexts.lock().ok().and_then(|m| { + m.get(&domain::RuntimeAgentKey::new(project_id, agent_id)) + .cloned() + }); let Some(ctx) = ctx else { return Err(AppError::NotFound(format!( "resume context for agent {agent_id}" @@ -818,6 +819,7 @@ impl AgentResumer for AppAgentResumer { requester: agent_id.to_string(), }); + let project_id = ctx.project.id; self.launch_agent .execute(LaunchAgentInput { project: ctx.project, @@ -840,7 +842,9 @@ impl AgentResumer for AppAgentResumer { "IdeA", resume_prompt, ); - let _ = self.input_mediator.enqueue(agent_id, ticket); + let _ = self + .input_mediator + .enqueue(domain::RuntimeAgentKey::new(project_id, agent_id), ticket); Ok(()) } @@ -1200,7 +1204,7 @@ pub struct BackendCore { /// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est /// annulable via la commande `cancel_resume`. pub session_limit_service: Arc, - /// Registre `agent_id → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] : + /// Registre `(project_id, agent_id) → ResumeContext` (LS7) partagé avec [`AppAgentResumer`] : /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. pub resume_contexts: ResumeContexts, @@ -1211,10 +1215,11 @@ pub struct BackendCore { /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du /// [`turn_watcher`](Self::turn_watcher) à l'armement. pub turn_watch_input: Arc, - /// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent - /// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter - /// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. - pub turn_watch_handles: Mutex>>, + /// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent + /// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; + /// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. + pub turn_watch_handles: + Mutex>>, /// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que /// l'inspecteur et le turn-watcher. @@ -2235,7 +2240,10 @@ impl BackendCore { created_at_ms: clock_for_items.now_millis().max(0) as u64, correlation_id: Some(ready.task_id.to_string()), }; - match inbox.enqueue_message(ready.owner_agent_id, item) { + match inbox.enqueue_message( + domain::RuntimeAgentKey::new(ready.project_id, ready.owner_agent_id), + item, + ) { Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => { application::diag!( "[background-task] completion deferred: task={} owner={} \ @@ -2822,9 +2830,11 @@ impl BackendCore { // L'id arrive en hex (handshake `requester`) ⇒ on le parse en AgentId ici (la // composition root est la seule à connaître la frontière infra↔domaine). let service_for_ready = Arc::clone(&self.orchestrator_service); + let ready_project = project.clone(); let ready_sink: Arc = Arc::new(move |requester: &str| { if let Ok(uuid) = Uuid::parse_str(requester) { - service_for_ready.release_agent_cold_start(AgentId::from_uuid(uuid)); + service_for_ready + .release_agent_cold_start(&ready_project, AgentId::from_uuid(uuid)); } }); let handle = McpServerHandle::start( @@ -2875,6 +2885,7 @@ impl BackendCore { /// no-op. `conversation_id` is diagnostic only. pub fn arm_turn_watch( &self, + project_id: ProjectId, project_root: &domain::project::ProjectPath, agent_id: AgentId, profile: &AgentProfile, @@ -2894,21 +2905,22 @@ impl BackendCore { }; let input = Arc::clone(&self.turn_watch_input); // Callback invoked from the watcher's polling task (no lock held here). - let on_turn_end: domain::ports::OnTurnEnd = Arc::new(move |a| input.turn_ended(a)); + let on_turn_end: domain::ports::OnTurnEnd = + Arc::new(move |a| input.turn_ended(domain::RuntimeAgentKey::new(project_id, a))); let handle = self .turn_watcher .watch(agent_id, conversation_id, cwd, on_turn_end); if let Ok(mut map) = self.turn_watch_handles.lock() { // Insert replaces (and drops) any prior handle ⇒ its polling task stops. - map.insert(agent_id, handle); + map.insert(domain::RuntimeAgentKey::new(project_id, agent_id), handle); } } - /// Stops and removes the end-of-turn watcher of `agent_id` (close / stop). Dropping - /// the stored handle stops its polling task. No-op if none is armed. - pub fn stop_turn_watch(&self, agent_id: AgentId) { + /// Stops and removes the end-of-turn watcher of `agent_id` in `project_id` (close / + /// stop). Dropping the stored handle stops its polling task. No-op if none is armed. + pub fn stop_turn_watch(&self, project_id: ProjectId, agent_id: AgentId) { if let Ok(mut map) = self.turn_watch_handles.lock() { - map.remove(&agent_id); + map.remove(&domain::RuntimeAgentKey::new(project_id, agent_id)); } } @@ -6326,7 +6338,7 @@ mod mcp_e2e_loopback_tests { "ask reply must be returned inline over the real loopback; got {result}" ); assert_eq!( - mailbox.pending(&agent_id), + mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)), 0, "structured ask must drain its accounting ticket" ); @@ -6385,7 +6397,7 @@ mod mcp_e2e_loopback_tests { "ask reply must be returned inline over the real loopback (Codex target); got {result}" ); assert_eq!( - mailbox.pending(&agent_id), + mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)), 0, "structured ask must drain its accounting ticket" ); diff --git a/crates/domain/src/conversation.rs b/crates/domain/src/conversation.rs index e91b84f..b21cdc5 100644 --- a/crates/domain/src/conversation.rs +++ b/crates/domain/src/conversation.rs @@ -22,7 +22,7 @@ use serde::{Deserialize, Serialize}; -use crate::ids::{AgentId, SessionId}; +use crate::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId}; /// Identifies one [`Conversation`]. /// @@ -68,19 +68,38 @@ impl ConversationId { /// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon /// commutative (XOR), stable et déterministe. #[must_use] - pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self { + pub fn for_project_pair( + project_id: ProjectId, + a: ConversationParty, + b: ConversationParty, + ) -> Self { match (a.as_agent(), b.as_agent()) { // User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`. - (Some(agent), None) | (None, Some(agent)) => Self::from_uuid(agent.as_uuid()), + (Some(agent), None) | (None, Some(agent)) => { + let key = RuntimeAgentKey::new(project_id, agent); + Self::from_uuid(uuid::Uuid::from_u128( + key.project_id.as_uuid().as_u128() ^ key.agent_id.as_uuid().as_u128(), + )) + } // Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre). (Some(x), Some(y)) => { - let xor = x.as_uuid().as_u128() ^ y.as_uuid().as_u128(); + let xor = + project_id.as_uuid().as_u128() ^ x.as_uuid().as_u128() ^ y.as_uuid().as_u128(); Self::from_uuid(uuid::Uuid::from_u128(xor)) } // User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic. (None, None) => Self::from_uuid(uuid::Uuid::nil()), } } + + /// Legacy deterministic pair id without project scoping. + /// + /// Runtime code must prefer [`Self::for_project_pair`]. Kept for persisted legacy + /// data and tests that intentionally exercise project-less values. + #[must_use] + pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self { + Self::for_project_pair(ProjectId::from_uuid(uuid::Uuid::nil()), a, b) + } } impl std::fmt::Display for ConversationId { @@ -322,7 +341,12 @@ pub trait ConversationRegistry: Send + Sync { /// Get-or-create: returns the thread for the pair `{a, b}`, opening it /// (`Dormant`) if it did not exist. Pure registry — opens **no** session. /// The same unordered pair always yields the same [`ConversationId`]. - fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation; + fn resolve( + &self, + project_id: ProjectId, + a: ConversationParty, + b: ConversationParty, + ) -> Conversation; /// Marks the conversation `id` `Live` with the given session reference. fn bind_session(&self, id: ConversationId, session: SessionRef); diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index 238e79b..c6a67da 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -115,3 +115,34 @@ typed_id!( /// Identifies a first-class background task. TaskId ); + +/// Runtime-only key for an agent scoped by its project. +/// +/// `AgentId` is persisted inside a project and is not globally unique across +/// simultaneously opened projects. Any app-wide in-memory registry that tracks +/// live runtime state for an agent must use this key instead of `AgentId` alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeAgentKey { + /// Project that owns the runtime agent. + pub project_id: ProjectId, + /// Agent inside that project. + pub agent_id: AgentId, +} + +impl RuntimeAgentKey { + /// Builds a scoped runtime key from its persisted identifiers. + #[must_use] + pub const fn new(project_id: ProjectId, agent_id: AgentId) -> Self { + Self { + project_id, + agent_id, + } + } +} + +impl std::fmt::Display for RuntimeAgentKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}:{}", self.project_id, self.agent_id) + } +} diff --git a/crates/domain/src/inbox.rs b/crates/domain/src/inbox.rs index 156197f..9edc647 100644 --- a/crates/domain/src/inbox.rs +++ b/crates/domain/src/inbox.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::ids::{AgentId, TaskId}; +use crate::ids::{AgentId, RuntimeAgentKey, TaskId}; use crate::mailbox::TicketId; /// Default bounded inbox capacity per agent. @@ -114,6 +114,8 @@ pub struct InboxReceipt { pub item_id: TicketId, /// Target agent. pub agent_id: AgentId, + /// Runtime-scoped target agent. + pub runtime_key: RuntimeAgentKey, /// FIFO depth after the operation. pub depth: usize, /// Enqueue outcome. @@ -126,6 +128,8 @@ pub struct InboxReceipt { pub struct AgentInboxSnapshot { /// Target agent. pub agent_id: AgentId, + /// Runtime-scoped target agent. + pub runtime_key: RuntimeAgentKey, /// Number of queued inbox items. pub depth: usize, /// FIFO-ordered items. @@ -162,11 +166,15 @@ pub trait AgentInbox: Send + Sync { /// /// # Errors /// [`InboxError`] when the item is invalid or a normal message overflows. - fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result; + fn enqueue_message( + &self, + agent: RuntimeAgentKey, + item: InboxItem, + ) -> Result; /// Pops the next queued inbox item, if any. - fn dequeue_next(&self, agent: AgentId) -> Option; + fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option; /// Returns the FIFO snapshot for `agent`. - fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot; + fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot; } diff --git a/crates/domain/src/input.rs b/crates/domain/src/input.rs index ab3df71..2f47986 100644 --- a/crates/domain/src/input.rs +++ b/crates/domain/src/input.rs @@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize}; -use crate::ids::AgentId; +use crate::ids::{AgentId, RuntimeAgentKey}; use crate::mailbox::{PendingReply, Ticket, TicketId}; use crate::ports::PtyHandle; @@ -162,7 +162,7 @@ pub trait InputMediator: Send + Sync { /// cell (sole owner of the terminal) runs the write-portal handshake and writes the /// text + submit sequence through the single PTY writer. The mediator stays the /// authority of the FIFO/busy state and correlation only. - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply; /// Headless/system enqueue: appends `ticket` to the same FIFO and marks the agent /// busy, but does **not** deliver any text to the human terminal surface. @@ -175,7 +175,7 @@ pub trait InputMediator: Send + Sync { /// /// Default keeps compatibility for simple mediators; production overrides it to /// suppress delivery. - fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { self.enqueue(agent, ticket) } @@ -186,7 +186,7 @@ pub trait InputMediator: Send + Sync { /// the orchestrator calls this once it has resolved/launched the agent's live /// session for the target conversation. Default: no-op (a mediator that does not /// own the delivery write). - fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} + fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {} /// Like [`InputMediator::bind_handle`], but also records the target's /// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo @@ -203,7 +203,12 @@ pub trait InputMediator: Send + Sync { /// /// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config. /// The infra adapter overrides it to stash the submit config. - fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) { + fn bind_handle_with_submit( + &self, + agent: RuntimeAgentKey, + handle: PtyHandle, + _submit: SubmitConfig, + ) { self.bind_handle(agent, handle); } @@ -212,7 +217,7 @@ pub trait InputMediator: Send + Sync { /// for source compatibility; it now always returns `false`. Callers should stop /// branching on it (the orchestrator no longer falls back to its own PTY write). #[must_use] - fn delivers_turn(&self, _agent: AgentId) -> bool { + fn delivers_turn(&self, _agent: RuntimeAgentKey) -> bool { false } @@ -228,7 +233,7 @@ pub trait InputMediator: Send + Sync { /// (chemin chaud, fallback sûr, zéro régression). /// /// Default: no-op (a mediator that does not gate cold starts). - fn mark_starting(&self, _agent: AgentId) {} + fn mark_starting(&self, _agent: RuntimeAgentKey) {} /// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se /// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour @@ -240,7 +245,7 @@ pub trait InputMediator: Send + Sync { /// watcher prompt-ready PTY ayant été supprimé. /// /// Default: no-op (médiateur qui ne gate pas les démarrages à froid). - fn release_cold_start(&self, _agent: AgentId) {} + fn release_cold_start(&self, _agent: RuntimeAgentKey) {} /// Déclare si une **cellule terminal du frontend** est montée pour `agent` /// (`true` au montage du write-portal, `false` au démontage). C'est le frontend @@ -253,14 +258,14 @@ pub trait InputMediator: Send + Sync { /// /// Default: no-op (médiateur sans notion de cellule front — tout passe par /// l'événement, comportement historique). - fn set_front_attached(&self, _agent: AgentId, _attached: bool) {} + fn set_front_attached(&self, _agent: RuntimeAgentKey, _attached: bool) {} /// Interrompre = preempt: signals the running turn to stop (Échap/stop). This /// is **not** an enqueue and correlates **no** ticket. - fn preempt(&self, agent: AgentId); + fn preempt(&self, agent: RuntimeAgentKey); /// Marks `agent` free (explicit signal) so its FIFO advances. - fn mark_idle(&self, agent: AgentId); + fn mark_idle(&self, agent: RuntimeAgentKey); /// **End-of-turn** signal: the agent's transcript just recorded a completed turn /// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a @@ -273,7 +278,7 @@ pub trait InputMediator: Send + Sync { /// Replaces the former prompt-ready watcher branch verbatim; only the **trigger** /// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default: /// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances). - fn turn_ended(&self, agent: AgentId) { + fn turn_ended(&self, agent: RuntimeAgentKey) { self.mark_idle(agent); } @@ -287,7 +292,7 @@ pub trait InputMediator: Send + Sync { /// Default: no-op (a mediator that does not track liveness). The infra adapter /// `MediatedInbox` overrides it to refresh `last_seen` and publish an /// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement. - fn mark_alive(&self, _agent: AgentId) {} + fn mark_alive(&self, _agent: RuntimeAgentKey) {} /// Declares the agent's **stall threshold** (its profile's /// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows @@ -298,10 +303,10 @@ pub trait InputMediator: Send + Sync { /// zero regression). /// /// Default: no-op (a mediator that does not track liveness). - fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option) {} + fn set_stall_threshold(&self, _agent: RuntimeAgentKey, _stall_after_ms: Option) {} /// The current [`AgentBusyState`] of `agent`. - fn busy_state(&self, agent: AgentId) -> AgentBusyState; + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState; } #[cfg(test)] diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 6b924e9..56ff7df 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -76,8 +76,8 @@ mod validation; pub use error::DomainError; pub use ids::{ - AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId, - SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, + AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey, + ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, }; pub use project::{Project, ProjectPath}; diff --git a/crates/domain/src/mailbox.rs b/crates/domain/src/mailbox.rs index e852b86..8af979c 100644 --- a/crates/domain/src/mailbox.rs +++ b/crates/domain/src/mailbox.rs @@ -30,7 +30,7 @@ use std::pin::Pin; use std::task::{Context, Poll}; use crate::conversation::ConversationId; -use crate::ids::AgentId; +use crate::ids::{AgentId, RuntimeAgentKey}; use crate::input::InputSource; /// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO. @@ -71,7 +71,7 @@ pub trait AgentQueueSnapshot: Send + Sync { /// /// An agent with no queue yields an empty `Vec`. Positions are recomputed from /// the current order (`0` = head). Pure read: the queue is left untouched. - fn queue_for(&self, agent: AgentId) -> Vec; + fn queue_for(&self, agent: RuntimeAgentKey) -> Vec; } /// Identifies one queued [`Ticket`] within a target agent's mailbox. @@ -283,7 +283,7 @@ pub trait AgentMailbox: Send + Sync { /// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`] /// feeds a result to this ticket (once it reaches the head and is answered), or /// to [`MailboxError::Cancelled`] if the reply channel closes first. - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply; + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply; /// Resolves the request at the **head** of `agent`'s FIFO with `result`, /// waking its awaiting [`PendingReply`] and removing it from the queue. @@ -294,7 +294,7 @@ pub trait AgentMailbox: Send + Sync { /// # Errors /// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an /// `idea_reply` with no matching ask in flight). - fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>; + fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError>; /// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO /// with `result`, waking its awaiting [`PendingReply`] and removing it from the @@ -314,7 +314,7 @@ pub trait AgentMailbox: Send + Sync { /// (and, for the default, when `agent`'s queue is empty). fn resolve_ticket( &self, - agent: AgentId, + agent: RuntimeAgentKey, _ticket_id: TicketId, result: String, ) -> Result<(), MailboxError> { @@ -326,7 +326,7 @@ pub trait AgentMailbox: Send + Sync { /// /// A no-op when the head is a different ticket (the timed-out one was already /// resolved, or another caller's ticket is now in front) — idempotent and safe. - fn cancel_head(&self, agent: AgentId, ticket_id: TicketId); + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId); /// Completes the turn of the ticket `ticket_id` **without** a reply, waking its /// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`]. @@ -346,7 +346,7 @@ pub trait AgentMailbox: Send + Sync { /// /// The default is a no-op: a mailbox that cannot wake a pending caller early simply /// falls back to the caller's timeout net (no behavioural change for it). - fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) { + fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { let _ = (agent, ticket_id); } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 5cccfcd..83cf50f 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -658,6 +658,8 @@ pub enum ScheduledTask { /// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté /// application, lot LS4). Porte exactement le pivot de reprise model-agnostique. ResumeAgent { + /// Projet hôte de l'agent à reprendre. + project_id: ProjectId, /// L'agent à reprendre. agent_id: AgentId, /// La cellule (nœud du layout) qui héberge sa session. diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 8c49285..5aaf921 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -1415,9 +1415,7 @@ mod mcp_tests { #[test] fn opencode_provider_config_rejects_empty_fields() { let secret_ref = crate::ports::SecretRef::new("secret-1"); - assert!( - OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err() - ); + assert!(OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()); assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err()); } @@ -1448,7 +1446,12 @@ mod mcp_tests { #[test] fn profile_with_opencode_provider_round_trips_camelcase() { - let provider = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-anthropic")).unwrap(); + let provider = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + crate::ports::SecretRef::new("secret-anthropic"), + ) + .unwrap(); let profile = profile_without_mcp() .with_structured_adapter(StructuredAdapter::OpenCode) .with_opencode_provider(provider.clone()); @@ -1469,7 +1472,12 @@ mod mcp_tests { None, ) .unwrap(); - let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap(); + let cloud = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + crate::ports::SecretRef::new("secret-cloud"), + ) + .unwrap(); let only_local = profile_without_mcp() .with_structured_adapter(StructuredAdapter::OpenCode) @@ -1504,7 +1512,12 @@ mod mcp_tests { None, ) .unwrap(); - let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap(); + let cloud = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + crate::ports::SecretRef::new("secret-cloud"), + ) + .unwrap(); // Setting the cloud provider clears a previously-set local one. let cloud_wins = profile_without_mcp() @@ -1513,7 +1526,10 @@ mod mcp_tests { .with_opencode_provider(cloud.clone()); assert!(cloud_wins.opencode_backend_is_consistent()); assert!(cloud_wins.opencode.is_none(), "stale local backend dropped"); - assert_eq!(cloud_wins.opencode_provider.as_ref().unwrap().provider_id, "anthropic"); + assert_eq!( + cloud_wins.opencode_provider.as_ref().unwrap().provider_id, + "anthropic" + ); // Setting the local provider clears a previously-set cloud one. let local_wins = profile_without_mcp() @@ -1521,13 +1537,21 @@ mod mcp_tests { .with_opencode_provider(cloud) .with_opencode(local); assert!(local_wins.opencode_backend_is_consistent()); - assert!(local_wins.opencode_provider.is_none(), "stale cloud backend dropped"); + assert!( + local_wins.opencode_provider.is_none(), + "stale cloud backend dropped" + ); assert!(local_wins.opencode.is_some()); } #[test] fn opencode_provider_config_serialises_no_local_model_server_id_leak() { - let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap(); + let config = OpenCodeProviderConfig::new( + "openrouter", + "some-model", + crate::ports::SecretRef::new("secret-openrouter"), + ) + .unwrap(); let json = serde_json::to_string(&config).expect("serialise"); assert!(!json.contains("localModelServerId")); assert!(!json.contains("baseURL")); diff --git a/crates/infrastructure/src/background_task/sink.rs b/crates/infrastructure/src/background_task/sink.rs index 66183c0..022ec54 100644 --- a/crates/infrastructure/src/background_task/sink.rs +++ b/crates/infrastructure/src/background_task/sink.rs @@ -195,7 +195,10 @@ pub fn start_background_ready_inbox_bridge( created_at_ms: now_ms(), correlation_id: Some(ready.task_id.to_string()), }; - match inbox.enqueue_message(ready.owner_agent_id, item) { + match inbox.enqueue_message( + domain::RuntimeAgentKey::new(ready.project_id, ready.owner_agent_id), + item, + ) { Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => { application::diag!( "[background-task] completion deferred: task={} owner={} queue_depth={}", diff --git a/crates/infrastructure/src/conversation/mod.rs b/crates/infrastructure/src/conversation/mod.rs index 0e5f48a..4fad4ac 100644 --- a/crates/infrastructure/src/conversation/mod.rs +++ b/crates/infrastructure/src/conversation/mod.rs @@ -17,6 +17,7 @@ use domain::conversation::{ Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession, SessionRef, }; +use domain::ids::ProjectId; /// Order-insensitive key for a conversation pair `{a, b}`. /// @@ -47,7 +48,7 @@ pub struct InMemoryConversationRegistry { #[derive(Default)] struct Inner { by_id: HashMap, - by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>, + by_pair: HashMap<(ProjectId, ConversationParty, ConversationParty), ConversationId>, } impl InMemoryConversationRegistry { @@ -79,10 +80,16 @@ impl InMemoryConversationRegistry { } impl ConversationRegistry for InMemoryConversationRegistry { - fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation { + fn resolve( + &self, + project_id: ProjectId, + a: ConversationParty, + b: ConversationParty, + ) -> Conversation { let key = pair_key(a, b); + let scoped_key = (project_id, key.0, key.1); let mut inner = self.lock(); - if let Some(id) = inner.by_pair.get(&key).copied() { + if let Some(id) = inner.by_pair.get(&scoped_key).copied() { // Existing thread for this pair — return its current snapshot. return inner .by_id @@ -97,10 +104,10 @@ impl ConversationRegistry for InMemoryConversationRegistry { // the persistence key stable and aligned with `LaunchAgent` (P8a) / the // `resolve_conversation` fallback. The pair is valid by construction at call // sites; `try_new` still guards the invariants. - let id = ConversationId::for_pair(key.0, key.1); + let id = ConversationId::for_project_pair(project_id, key.0, key.1); let conv = Conversation::try_new(id, key.0, key.1) .expect("pair_key yields a valid distinct/≤1-user pair"); - inner.by_pair.insert(key, id); + inner.by_pair.insert(scoped_key, id); inner.by_id.insert(id, conv.clone()); conv } @@ -136,14 +143,18 @@ mod tests { ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) } + fn project(n: u128) -> ProjectId { + ProjectId::from_uuid(uuid::Uuid::from_u128(n)) + } + #[test] fn resolve_is_lazy_get_or_create() { let reg = InMemoryConversationRegistry::new(); assert!(reg.is_empty()); - let c = reg.resolve(ConversationParty::User, agent(1)); + let c = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(reg.len(), 1); // Same pair ⇒ same id, no new conversation created. - let c2 = reg.resolve(ConversationParty::User, agent(1)); + let c2 = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(c.id, c2.id); assert_eq!(reg.len(), 1); } @@ -151,8 +162,8 @@ mod tests { #[test] fn same_pair_unordered_yields_same_id() { let reg = InMemoryConversationRegistry::new(); - let c1 = reg.resolve(agent(1), agent(2)); - let c2 = reg.resolve(agent(2), agent(1)); // swapped order + let c1 = reg.resolve(project(1), agent(1), agent(2)); + let c2 = reg.resolve(project(1), agent(2), agent(1)); // swapped order assert_eq!(c1.id, c2.id, "unordered pair identity"); assert_eq!(reg.len(), 1); } @@ -160,8 +171,8 @@ mod tests { #[test] fn distinct_pairs_get_distinct_ids() { let reg = InMemoryConversationRegistry::new(); - let user_b = reg.resolve(ConversationParty::User, agent(2)); - let a_b = reg.resolve(agent(1), agent(2)); + let user_b = reg.resolve(project(1), ConversationParty::User, agent(2)); + let a_b = reg.resolve(project(1), agent(1), agent(2)); assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads"); assert_eq!(reg.len(), 2); } @@ -169,14 +180,14 @@ mod tests { #[test] fn fresh_resolve_is_dormant() { let reg = InMemoryConversationRegistry::new(); - let c = reg.resolve(ConversationParty::User, agent(1)); + let c = reg.resolve(project(1), ConversationParty::User, agent(1)); assert_eq!(c.session, ConversationSession::Dormant); } #[test] fn bind_session_makes_it_live_then_suspend_restores_dormant() { let reg = InMemoryConversationRegistry::new(); - let c = reg.resolve(ConversationParty::User, agent(1)); + let c = reg.resolve(project(1), ConversationParty::User, agent(1)); let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99))); reg.bind_session(c.id, sref); let live = reg.get(c.id).unwrap(); @@ -210,10 +221,10 @@ mod tests { // l'IDE : la même paire User↔Agent doit produire le **même** id. let a = agent(7); let first = InMemoryConversationRegistry::new() - .resolve(ConversationParty::User, a) + .resolve(project(1), ConversationParty::User, a) .id; let second = InMemoryConversationRegistry::new() - .resolve(ConversationParty::User, a) + .resolve(project(1), ConversationParty::User, a) .id; assert_eq!( first, second, @@ -226,8 +237,12 @@ mod tests { // Même garantie pour une paire Agent↔Agent (dérivation XOR commutative). let x = agent(11); let y = agent(13); - let first = InMemoryConversationRegistry::new().resolve(x, y).id; - let second = InMemoryConversationRegistry::new().resolve(y, x).id; + let first = InMemoryConversationRegistry::new() + .resolve(project(1), x, y) + .id; + let second = InMemoryConversationRegistry::new() + .resolve(project(1), y, x) + .id; assert_eq!( first, second, "id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre" @@ -242,18 +257,13 @@ mod tests { let agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42)); let party = ConversationParty::agent(agent_id); let resolved = InMemoryConversationRegistry::new() - .resolve(ConversationParty::User, party) + .resolve(project(1), ConversationParty::User, party) .id; assert_eq!( resolved, - ConversationId::for_pair(ConversationParty::User, party), + ConversationId::for_project_pair(project(1), ConversationParty::User, party), "resolve == for_pair (alignement de clé)" ); - assert_eq!( - resolved, - ConversationId::from_uuid(agent_id.as_uuid()), - "User↔Agent ⇒ id == uuid de l'agent (repli resolve_conversation)" - ); } #[test] @@ -262,12 +272,12 @@ mod tests { let x = agent(101); let y = agent(202); let reg = InMemoryConversationRegistry::new(); - let id_xy = reg.resolve(x, y).id; - let id_yx = reg.resolve(y, x).id; + let id_xy = reg.resolve(project(1), x, y).id; + let id_yx = reg.resolve(project(1), y, x).id; assert_eq!(id_xy, id_yx, "resolve(a,b) == resolve(b,a)"); assert_eq!( id_xy, - ConversationId::for_pair(x, y), + ConversationId::for_project_pair(project(1), x, y), "resolve == for_pair (Agent↔Agent)" ); assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}"); @@ -277,11 +287,25 @@ mod tests { fn distinct_pairs_yield_distinct_ids_across_kinds() { // Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé). let reg = InMemoryConversationRegistry::new(); - let user_a = reg.resolve(ConversationParty::User, agent(1)).id; - let user_b = reg.resolve(ConversationParty::User, agent(2)).id; - let a_b = reg.resolve(agent(1), agent(2)).id; + let user_a = reg + .resolve(project(1), ConversationParty::User, agent(1)) + .id; + let user_b = reg + .resolve(project(1), ConversationParty::User, agent(2)) + .id; + let a_b = reg.resolve(project(1), agent(1), agent(2)).id; assert_ne!(user_a, user_b, "User↔A ≠ User↔B"); assert_ne!(user_a, a_b, "User↔A ≠ A↔B"); assert_ne!(user_b, a_b, "User↔B ≠ A↔B"); } + + #[test] + fn same_agent_ids_in_distinct_projects_get_distinct_threads() { + let reg = InMemoryConversationRegistry::new(); + let p1 = reg.resolve(project(1), ConversationParty::User, agent(7)); + let p2 = reg.resolve(project(2), ConversationParty::User, agent(7)); + + assert_ne!(p1.id, p2.id); + assert_eq!(reg.len(), 2); + } } diff --git a/crates/infrastructure/src/input/mod.rs b/crates/infrastructure/src/input/mod.rs index 68cf453..ec4dbcd 100644 --- a/crates/infrastructure/src/input/mod.rs +++ b/crates/infrastructure/src/input/mod.rs @@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use domain::events::DomainEvent; -use domain::ids::AgentId; +use domain::ids::RuntimeAgentKey; use domain::inbox::{ AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus, InboxSource, DEFAULT_AGENT_INBOX_CAPACITY, @@ -44,11 +44,11 @@ use crate::mailbox::InMemoryMailbox; /// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so /// every path (explicit `mark_idle`, prompt-ready match) stays consistent. struct BusyTracker { - busy: Mutex>, + busy: Mutex>, /// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de /// stagnation issu du profil, et état de vivacité courant pour n'émettre /// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam). - liveness: Mutex>, + liveness: Mutex>, /// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement /// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée* /// sur la readiness MCP. Un agent y est inscrit par @@ -56,11 +56,11 @@ struct BusyTracker { /// consommé par l'`enqueue` qui démarre le tour : la `DelegationReady` /// est alors **différée** dans `deferred` au lieu d'être publiée immédiatement (le /// CLI n'a pas encore chargé ses outils MCP). Vide ⇒ comportement chaud inchangé. - starting: Mutex>, + starting: Mutex>, /// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue /// pour un démarrage à froid, publiée par [`BusyTracker::release_cold_start`] à la /// connexion du pont MCP (jamais avant). Absent ⇒ aucun tour en attente de gate. - deferred: Mutex>, + deferred: Mutex>, /// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des /// agents pour lesquels le signal de readiness (`release_cold_start`, pont MCP) est /// arrivé **avant** que l'`enqueue` n'ait parqué son tour dans @@ -71,7 +71,7 @@ struct BusyTracker { /// latch enregistre « cet agent en démarrage est déjà prêt » : l'`enqueue` qui suit /// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison /// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé. - released: Mutex>, + released: Mutex>, events: Option>, /// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par /// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est @@ -118,7 +118,7 @@ struct DeferredDelegation { /// normal (cellule frontend présente ⇒ c'est le write-portal qui écrira, ou pas de /// PTY/handle disponible ⇒ repli sur l'événement, comportement historique). type HeadlessSink = - Arc Option + Send + Sync>; + Arc Option + Send + Sync>; /// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de /// soumission lors d'une livraison **headless** (le médiateur écrit lui-même le PTY). @@ -162,19 +162,21 @@ impl BusyTracker { } } - fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet> { + fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet> { self.starting .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap> { + fn lock_deferred( + &self, + ) -> std::sync::MutexGuard<'_, HashMap> { self.deferred .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet> { + fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet> { self.released .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -186,28 +188,28 @@ impl BusyTracker { /// (`release_cold_start`), sinon le premier tour resterait bloqué indéfiniment /// (aucun signal ne viendrait le libérer). Sans cet appel, l'`enqueue` publie la /// `DelegationReady` immédiatement (chemin chaud, zéro régression). - fn mark_starting(&self, agent: AgentId) { + fn mark_starting(&self, agent: RuntimeAgentKey) { application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}"); self.lock_starting().insert(agent); } - fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap> { self.busy .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap> { + fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap> { self.liveness .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Publie un `AgentLivenessChanged` (si un bus est câblé). - fn publish_liveness(&self, agent: AgentId, liveness: AgentLiveness) { + fn publish_liveness(&self, agent: RuntimeAgentKey, liveness: AgentLiveness) { if let Some(events) = &self.events { events.publish(DomainEvent::AgentLivenessChanged { - agent_id: agent, + agent_id: agent.agent_id, liveness, }); } @@ -217,7 +219,7 @@ impl BusyTracker { /// démarre (depuis l'enqueue) : (re)initialise `last_seen` à `now` et repart d'un /// état `Alive`. Sans seuil (`None`), l'entrée existe quand même mais le sweep ne /// la déclarera jamais `Stalled` (zéro régression pour un profil sans liveness). - fn arm_liveness(&self, agent: AgentId, stall_after_ms: Option, now_ms: u64) { + fn arm_liveness(&self, agent: RuntimeAgentKey, stall_after_ms: Option, now_ms: u64) { self.lock_liveness().insert( agent, LivenessState { @@ -231,7 +233,7 @@ impl BusyTracker { /// Rafraîchit le `last_seen` d'un agent (un **battement**) et, s'il était /// `Stalled`, le ramène à `Alive` en émettant l'unique transition de reprise. No-op /// si l'agent n'a pas d'entrée de vivacité armée (tour non structuré / legacy). - fn touch(&self, agent: AgentId, now_ms: u64) { + fn touch(&self, agent: RuntimeAgentKey, now_ms: u64) { let recovered = { let mut map = self.lock_liveness(); let Some(state) = map.get_mut(&agent) else { @@ -256,7 +258,7 @@ impl BusyTracker { /// `now_ms` est passé en paramètre. Idempotente : un agent déjà `Stalled` ne ré-émet /// pas. Un agent sans seuil (`None`) ou redevenu `Idle` n'est jamais déclaré stalled. fn sweep_stalled(&self, now_ms: u64) { - let newly_stalled: Vec = { + let newly_stalled: Vec = { let mut map = self.lock_liveness(); map.iter_mut() .filter_map(|(agent, state)| { @@ -285,7 +287,7 @@ impl BusyTracker { /// Retire l'entrée de vivacité d'un agent (fin de tour). Si l'agent était `Stalled`, /// la fin de tour est en soi un retour à `Alive` ⇒ on émet la transition de reprise. - fn clear_liveness(&self, agent: AgentId) { + fn clear_liveness(&self, agent: RuntimeAgentKey) { let was_stalled = self .lock_liveness() .remove(&agent) @@ -295,7 +297,7 @@ impl BusyTracker { } } - fn busy_state(&self, agent: AgentId) -> AgentBusyState { + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState { self.lock() .get(&agent) .copied() @@ -304,7 +306,7 @@ impl BusyTracker { /// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually /// started (so the caller publishes `AgentBusyChanged{busy:true}` only once). - fn start_turn(&self, agent: AgentId, state: AgentBusyState) -> bool { + fn start_turn(&self, agent: RuntimeAgentKey, state: AgentBusyState) -> bool { let mut busy = self.lock(); let entry = busy.entry(agent).or_insert(AgentBusyState::Idle); if entry.is_busy() { @@ -320,7 +322,7 @@ impl BusyTracker { /// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus /// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment /// où son prompt apparaît. - fn publish_deferred(&self, agent: AgentId, d: DeferredDelegation) { + fn publish_deferred(&self, agent: RuntimeAgentKey, d: DeferredDelegation) { // Point de livraison unique (tours chauds immédiats ET drains à froid). Si un // sink headless est câblé, il a la priorité : pour un agent sans cellule // frontend il écrit lui-même la tâche dans le PTY et renvoie `None` (pris en @@ -346,7 +348,7 @@ impl BusyTracker { d.submit_delay_ms, ); events.publish(DomainEvent::DelegationReady { - agent_id: agent, + agent_id: agent.agent_id, ticket: d.ticket, text: d.text, submit_sequence: d.submit_sequence, @@ -368,7 +370,7 @@ impl BusyTracker { /// une erreur typée). Un `idea_reply` arrivé pendant G gagne (la complétion devient un /// no-op, tête déjà retirée). Sur un agent déjà `Idle` (aucun ticket actif) ⇒ simple /// `mark_idle` idempotent, pas de grâce. - fn turn_ended(&self, agent: AgentId) { + fn turn_ended(&self, agent: RuntimeAgentKey) { let active = self.lock().get(&agent).and_then(AgentBusyState::ticket); application::diag!( "[input-mediator] turn_ended agent={agent} no_reply_payload -> mark_idle + grace" @@ -395,7 +397,7 @@ impl BusyTracker { /// l'agent de `starting`) ; sinon no-op. **Pas de `mark_idle`** (signal de démarrage, /// pas de fin de tour). Idempotent : c'est le seul drain du tour différé (le `remove` /// ne rend `Some` qu'une fois). - fn release_cold_start(&self, agent: AgentId) { + fn release_cold_start(&self, agent: RuntimeAgentKey) { let was_starting = self.lock_starting().remove(&agent); if let Some(d) = self.lock_deferred().remove(&agent) { // Cas nominal : l'`enqueue` a déjà parqué le tour ⇒ on le draine. @@ -421,7 +423,7 @@ impl BusyTracker { /// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real /// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a /// no-op and emits nothing. - fn mark_idle(&self, agent: AgentId) { + fn mark_idle(&self, agent: RuntimeAgentKey) { let was_busy = { let mut busy = self.lock(); busy.insert(agent, AgentBusyState::Idle) @@ -431,7 +433,7 @@ impl BusyTracker { application::diag!("[input-mediator] mark_idle turn ended agent={agent}"); if let Some(events) = &self.events { events.publish(DomainEvent::AgentBusyChanged { - agent_id: agent, + agent_id: agent.agent_id, busy: false, }); } @@ -485,21 +487,21 @@ pub struct MediatedInbox { /// Per-agent live input handle (one stream per agent), fed by `bind_handle`. /// `Arc` so the headless delivery sink (wired into the [`BusyTracker`]) can read it /// to resolve an agent's PTY handle when it must write the turn itself. - handles: Arc>>, + handles: Arc>>, /// Agents qui ont une **cellule terminal frontend montée** (write-portal actif), /// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure, /// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon /// (agent headless / délégué en arrière-plan) le médiateur écrit lui-même le tour /// dans le PTY. `Arc` car le sink headless du tracker le consulte. - front_owned: Arc>>, + front_owned: Arc>>, /// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`), /// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a /// turn starts. Absent ⇒ both `None` (the front applies its defaults). - submit: Mutex>, + submit: Mutex>, /// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by /// `set_stall_threshold` and consumed to arm a fresh liveness window on the enqueue /// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour). - stall: Mutex>>, + stall: Mutex>>, /// Rich inbox payloads keyed by the mailbox ticket id. The mailbox remains the /// only FIFO; this map is metadata only. inbox_items: Mutex>, @@ -545,8 +547,8 @@ impl MediatedInbox { fn build_tracker( events: Option>, pty: Option<&Arc>, - handles: &Arc>>, - front_owned: &Arc>>, + handles: &Arc>>, + front_owned: &Arc>>, mailbox: &Arc, grace: Duration, ) -> Arc { @@ -572,10 +574,10 @@ impl MediatedInbox { /// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant. fn make_headless_sink( pty: Arc, - handles: Arc>>, - front_owned: Arc>>, + handles: Arc>>, + front_owned: Arc>>, ) -> HeadlessSink { - Arc::new(move |agent: AgentId, d: DeferredDelegation| { + Arc::new(move |agent: RuntimeAgentKey, d: DeferredDelegation| { // Cellule frontend montée ⇒ c'est le write-portal qui écrit (et qui sait // composer avec une saisie humaine en cours). On rend la main. if front_owned @@ -749,19 +751,19 @@ impl MediatedInbox { self } - fn handles(&self) -> std::sync::MutexGuard<'_, HashMap> { + fn handles(&self) -> std::sync::MutexGuard<'_, HashMap> { self.handles .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn submit(&self) -> std::sync::MutexGuard<'_, HashMap> { + fn submit(&self) -> std::sync::MutexGuard<'_, HashMap> { self.submit .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } - fn stall(&self) -> std::sync::MutexGuard<'_, HashMap>> { + fn stall(&self) -> std::sync::MutexGuard<'_, HashMap>> { self.stall .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -843,7 +845,7 @@ fn describe_submit_sequence(value: Option<&str>) -> String { } impl InputMediator for MediatedInbox { - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; // If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already // Busy, we still accept (queue grows; the turn advances on mark_idle) — never @@ -875,7 +877,7 @@ impl InputMediator for MediatedInbox { if started_turn { if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentBusyChanged { - agent_id: agent, + agent_id: agent.agent_id, busy: true, }); let submit = self.submit().get(&agent).cloned().unwrap_or_default(); @@ -921,7 +923,7 @@ impl InputMediator for MediatedInbox { self.mailbox.enqueue(agent, ticket) } - fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { let ticket_id = ticket.id; // Headless/system turns share the same FIFO and busy/liveness accounting as // terminal-delivered turns, but their prompt is sent through AgentSession::send. @@ -940,7 +942,7 @@ impl InputMediator for MediatedInbox { self.tracker.arm_liveness(agent, stall_after_ms, now_ms); if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentBusyChanged { - agent_id: agent, + agent_id: agent.agent_id, busy: true, }); } @@ -948,7 +950,7 @@ impl InputMediator for MediatedInbox { self.mailbox.enqueue(agent, ticket) } - fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { + fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) { eprintln!( "[input-mediator] bind handle agent={agent} handle={}", handle.session_id @@ -956,7 +958,12 @@ impl InputMediator for MediatedInbox { self.handles().insert(agent, handle); } - fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, submit: SubmitConfig) { + fn bind_handle_with_submit( + &self, + agent: RuntimeAgentKey, + handle: PtyHandle, + submit: SubmitConfig, + ) { // Register the input handle exactly like `bind_handle` and stash the target's // submit config (echoed on `DelegationReady` at the next turn start, §20.3). // Turn-end detection is NO LONGER armed here: the dead PTY prompt-ready watcher @@ -972,11 +979,11 @@ impl InputMediator for MediatedInbox { self.submit().insert(agent, submit); } - fn delivers_turn(&self, agent: AgentId) -> bool { + fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool { self.pty.is_some() && self.handles().get(&agent).is_some() } - fn mark_starting(&self, agent: AgentId) { + fn mark_starting(&self, agent: RuntimeAgentKey) { // Gate du premier tour d'un agent froid : appelé par l'orchestrateur juste après // un (re)lancement à froid, AVANT le bind/enqueue, et uniquement si un signal de // readiness le libérera (pont MCP). Consommé par l'`enqueue` qui démarre le tour @@ -984,11 +991,11 @@ impl InputMediator for MediatedInbox { self.tracker.mark_starting(agent); } - fn release_cold_start(&self, agent: AgentId) { + fn release_cold_start(&self, agent: RuntimeAgentKey) { self.tracker.release_cold_start(agent); } - fn set_front_attached(&self, agent: AgentId, attached: bool) { + fn set_front_attached(&self, agent: RuntimeAgentKey, attached: bool) { let mut front = self .front_owned .lock() @@ -1002,7 +1009,7 @@ impl InputMediator for MediatedInbox { } } - fn preempt(&self, agent: AgentId) { + fn preempt(&self, agent: RuntimeAgentKey) { // Interrompre: signals the running turn to stop. It is NOT an enqueue and // correlates **no** ticket (we never pop/resolve a pending caller — preempt // must never silently answer one). The only effect is a best-effort interrupt @@ -1018,42 +1025,46 @@ impl InputMediator for MediatedInbox { } } - fn mark_idle(&self, agent: AgentId) { + fn mark_idle(&self, agent: RuntimeAgentKey) { // Single authority: real Busy→Idle only, publishing AgentBusyChanged{busy:false} // once. Also clears the liveness entry (fin de tour) — émet la reprise si l'agent // était `Stalled`. self.tracker.mark_idle(agent); } - fn turn_ended(&self, agent: AgentId) { + fn turn_ended(&self, agent: RuntimeAgentKey) { // Déclenché par le `TurnWatcher` transcript (Claude `turn_duration`) : fin de tour // sans `idea_reply` ⇒ `mark_idle` + fenêtre de grâce pour réveiller l'appelant // parqué (cf. [`BusyTracker::turn_ended`]). Backstop no-reply. self.tracker.turn_ended(agent); } - fn mark_alive(&self, agent: AgentId) { + fn mark_alive(&self, agent: RuntimeAgentKey) { // Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène // l'agent à `Alive` s'il était `Stalled` (lot 2). self.tracker.touch(agent, self.clock.now_ms()); } - fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option) { + fn set_stall_threshold(&self, agent: RuntimeAgentKey, stall_after_ms: Option) { // Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui // démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2). self.stall().insert(agent, stall_after_ms); } - fn busy_state(&self, agent: AgentId) -> AgentBusyState { + fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState { self.tracker.busy_state(agent) } } impl AgentInbox for MediatedInbox { - fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result { - if item.agent_id != agent { + fn enqueue_message( + &self, + agent: RuntimeAgentKey, + item: InboxItem, + ) -> Result { + if item.agent_id != agent.agent_id { return Err(InboxError::AgentMismatch { - agent_id: agent, + agent_id: agent.agent_id, item_agent_id: item.agent_id, }); } @@ -1063,13 +1074,14 @@ impl AgentInbox for MediatedInbox { if item.is_lossless_system() { return Ok(InboxReceipt { item_id: item.id, - agent_id: agent, + agent_id: agent.agent_id, + runtime_key: agent, depth: depth_before, status: InboxReceiptStatus::Deferred, }); } return Err(InboxError::InboxFull { - agent_id: agent, + agent_id: agent.agent_id, capacity: self.inbox_capacity, }); } @@ -1081,32 +1093,33 @@ impl AgentInbox for MediatedInbox { let depth = self.mailbox.pending(&agent); if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentInboxQueued { - agent_id: agent, + agent_id: agent.agent_id, depth, }); } Ok(InboxReceipt { item_id, - agent_id: agent, + agent_id: agent.agent_id, + runtime_key: agent, depth, status: InboxReceiptStatus::Queued, }) } - fn dequeue_next(&self, agent: AgentId) -> Option { + fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option { let head = self.mailbox.head_ticket(&agent)?; let item = self.inbox_items().remove(&head)?; self.mailbox.cancel_head(agent, head); if let Some(events) = &self.tracker.events { events.publish(DomainEvent::AgentInboxDrained { - agent_id: agent, + agent_id: agent.agent_id, depth: self.snapshot(agent).depth, }); } Some(item) } - fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot { + fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot { let items_by_id = self.inbox_items(); let items: Vec = self .mailbox @@ -1115,7 +1128,8 @@ impl AgentInbox for MediatedInbox { .filter_map(|ticket| items_by_id.get(&ticket.id).cloned()) .collect(); AgentInboxSnapshot { - agent_id: agent, + agent_id: agent.agent_id, + runtime_key: agent, depth: self.mailbox.pending(&agent), items, } @@ -1152,6 +1166,7 @@ mod tests { use super::*; use domain::conversation::ConversationId; use domain::mailbox::TicketId; + use domain::AgentId; /// Deterministic clock for assertions on `since_ms`. struct FixedClock(u64); @@ -1165,6 +1180,13 @@ mod tests { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn key(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new( + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(1000 + n)), + agent(n), + ) + } + fn ticket(n: u128, task: &str) -> Ticket { Ticket::from_human( TicketId::from_uuid(uuid::Uuid::from_u128(n)), @@ -1240,27 +1262,33 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // First enqueue starts a turn ⇒ exactly one Busy(true) event. inbox.enqueue(a, ticket(10, "first")); - assert_eq!(bus.busy_events(), vec![(a, true)]); + assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); // Second enqueue while Busy queues behind ⇒ NO new busy event. inbox.enqueue(a, ticket(11, "second")); assert_eq!( bus.busy_events(), - vec![(a, true)], + vec![(a.agent_id, true)], "no re-announce while busy" ); // mark_idle on a busy agent ⇒ exactly one Idle(false) event. inbox.mark_idle(a); - assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]); + assert_eq!( + bus.busy_events(), + vec![(a.agent_id, true), (a.agent_id, false)] + ); // mark_idle on an already-idle agent ⇒ no spurious event. inbox.mark_idle(a); - assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]); + assert_eq!( + bus.busy_events(), + vec![(a.agent_id, true), (a.agent_id, false)] + ); } #[test] @@ -1268,11 +1296,11 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.enqueue(a, ticket(10, "t")); inbox.preempt(a); // Only the enqueue's Busy(true); preempt does not toggle busy state. - assert_eq!(bus.busy_events(), vec![(a, true)]); + assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); } // ==================================================================== @@ -1284,7 +1312,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket. inbox.enqueue(a, ticket(10, "do the thing")); @@ -1325,14 +1353,14 @@ mod tests { Arc::clone(&pty) as Arc, ) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.set_front_attached(a, true); inbox.enqueue_silent(a, ticket(10, "headless turn")); assert!(inbox.busy_state(a).is_busy()); - assert_eq!(bus.busy_events(), vec![(a, true)]); + assert_eq!(bus.busy_events(), vec![(a.agent_id, true)]); assert!( bus.delegation_ready().is_empty(), "headless bookkeeping must not leak a prompt to the terminal" @@ -1349,11 +1377,11 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); let task_id = domain::TaskId::from_uuid(uuid::Uuid::from_u128(42)); let item = domain::InboxItem { id: TicketId::from_uuid(uuid::Uuid::from_u128(10)), - agent_id: a, + agent_id: a.agent_id, source: InboxSource::BackgroundTask { task_id }, kind: domain::InboxItemKind::BackgroundCompletion, body: "Background task completed.".to_owned(), @@ -1384,7 +1412,7 @@ mod tests { Arc::clone(&pty) as Arc, ) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); let h = handle(1); // Bind the target's submit config (resolved from its profile by the service). @@ -1405,7 +1433,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.enqueue(a, ticket(10, "task")); let ready = bus.delegation_ready(); assert_eq!(ready.len(), 1); @@ -1428,7 +1456,7 @@ mod tests { Arc::clone(&pty) as Arc, ) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.set_front_attached(a, true); @@ -1460,7 +1488,7 @@ mod tests { Arc::clone(&pty) as Arc, ) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // No set_front_attached ⇒ headless. inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); @@ -1495,7 +1523,7 @@ mod tests { Arc::clone(&pty) as Arc, ) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); let task = format!("début {} fin", "x".repeat(1200)); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); @@ -1531,7 +1559,7 @@ mod tests { #[tokio::test] async fn enqueue_returns_pending_reply_resolved_via_mailbox() { let inbox = inbox_at(5); - let a = agent(1); + let a = key(1); let pending = inbox.enqueue(a, ticket(10, "do X")); // Resolve through the shared mailbox (the orchestrator's path). inbox.mailbox().resolve(a, "done".to_owned()).unwrap(); @@ -1544,7 +1572,7 @@ mod tests { #[test] fn first_enqueue_marks_busy_with_ticket_and_stamp() { let inbox = inbox_at(1234); - let a = agent(1); + let a = key(1); assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); inbox.enqueue(a, ticket(10, "t")); assert_eq!( @@ -1559,7 +1587,7 @@ mod tests { #[test] fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() { let inbox = inbox_at(1); - let a = agent(1); + let a = key(1); inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. @@ -1573,7 +1601,7 @@ mod tests { #[test] fn mark_idle_returns_to_idle_so_next_turn_can_start() { let inbox = inbox_at(1); - let a = agent(1); + let a = key(1); inbox.enqueue(a, ticket(10, "t")); assert!(inbox.busy_state(a).is_busy()); inbox.mark_idle(a); @@ -1589,7 +1617,7 @@ mod tests { #[tokio::test] async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() { let inbox = inbox_at(1); - let a = agent(1); + let a = key(1); let pending = inbox.enqueue(a, ticket(10, "t")); inbox.preempt(a); // preempt did not pop/resolve the ticket: still pending in the mailbox. @@ -1605,7 +1633,7 @@ mod tests { #[test] fn two_enqueues_same_agent_serialise_in_one_fifo() { let inbox = inbox_at(1); - let a = agent(1); + let a = key(1); inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(11, "second")); assert_eq!(inbox.mailbox().pending(&a), 2); @@ -1619,8 +1647,8 @@ mod tests { #[test] fn different_agents_are_independent_not_blocking() { let inbox = inbox_at(1); - let a = agent(1); - let b = agent(2); + let a = key(1); + let b = key(2); inbox.enqueue(a, ticket(10, "a")); inbox.enqueue(b, ticket(20, "b")); assert!(inbox.busy_state(a).is_busy()); @@ -1721,7 +1749,7 @@ mod tests { /// blocage jusqu'au timeout long (le bug corrigé). #[tokio::test] async fn turn_ended_without_reply_wakes_caller_after_grace() { - let a = agent(1); + let a = key(1); let inbox = inbox_grace(Duration::from_millis(40)); let pending = inbox.enqueue(a, ticket(10, "task")); @@ -1747,7 +1775,7 @@ mod tests { /// et la complétion de grâce (tête déjà retirée) est un no-op. #[tokio::test] async fn reply_before_turn_ended_wins() { - let a = agent(1); + let a = key(1); let inbox = inbox_grace(Duration::from_millis(40)); let pending = inbox.enqueue(a, ticket(10, "task")); @@ -1769,7 +1797,7 @@ mod tests { /// avec une grâce longue, on résout via la mailbox avant son expiration. #[tokio::test] async fn reply_within_grace_after_turn_ended_wins() { - let a = agent(1); + let a = key(1); // Long grace ⇒ the reply lands well within it. let inbox = inbox_grace(Duration::from_secs(30)); @@ -1796,7 +1824,7 @@ mod tests { /// été retirée par la complétion) — typé, idempotent, jamais un panic. #[tokio::test] async fn reply_after_grace_is_unmatched() { - let a = agent(1); + let a = key(1); let inbox = inbox_grace(Duration::from_millis(30)); let pending = inbox.enqueue(a, ticket(10, "task")); @@ -1822,7 +1850,7 @@ mod tests { /// retirer la tête — `complete_without_reply` skip quand le receiver est fermé. #[test] fn fire_and_forget_head_is_preserved_through_grace() { - let a = agent(1); + let a = key(1); let inbox = inbox_grace(Duration::from_millis(20)); // Human submit: the reply handle is dropped immediately (not awaited). @@ -1841,7 +1869,7 @@ mod tests { /// idempotent, aucune grâce armée, aucun panic. #[test] fn turn_ended_on_idle_agent_is_noop() { - let a = agent(1); + let a = key(1); let inbox = inbox_grace(Duration::from_millis(20)); inbox.turn_ended(a); // jamais de tour démarré. assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); @@ -1859,7 +1887,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur). inbox.mark_starting(a); @@ -1893,7 +1921,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur). inbox.mark_starting(a); @@ -1927,7 +1955,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting). inbox.enqueue(a, ticket(10, "task")); @@ -1952,7 +1980,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.mark_starting(a); inbox.enqueue(a, ticket(10, "cold task")); @@ -1973,7 +2001,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Pas de mark_starting ⇒ agent chaud. inbox.enqueue(a, ticket(10, "warm task")); @@ -1994,7 +2022,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); // Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting. inbox.enqueue(a, ticket(10, "task")); @@ -2017,7 +2045,7 @@ mod tests { let bus = Arc::new(RecordingBus::default()); let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) .with_events(Arc::clone(&bus) as Arc); - let a = agent(1); + let a = key(1); inbox.mark_starting(a); inbox.enqueue(a, ticket(10, "first")); @@ -2091,7 +2119,7 @@ mod tests { fn no_heartbeat_past_threshold_marks_stalled() { let clock = MutClock::new(1_000); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); // Profil : seuil de stagnation à 30_000 ms. Armé avant le tour. inbox.set_stall_threshold(a, Some(30_000)); @@ -2109,14 +2137,17 @@ mod tests { // Au-delà du seuil : exactement une transition Stalled. clock.set(1_000 + 30_001); inbox.sweep_stalled(); - assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + assert_eq!( + bus.liveness_events(), + vec![(a.agent_id, AgentLiveness::Stalled)] + ); // Idempotent : un second sweep ne ré-émet pas. clock.set(1_000 + 60_000); inbox.sweep_stalled(); assert_eq!( bus.liveness_events(), - vec![(a, AgentLiveness::Stalled)], + vec![(a.agent_id, AgentLiveness::Stalled)], "déjà stalled ⇒ pas de spam" ); } @@ -2126,7 +2157,7 @@ mod tests { fn heartbeat_within_window_resets_last_seen() { let clock = MutClock::new(1_000); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); inbox.set_stall_threshold(a, Some(30_000)); inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000 @@ -2149,7 +2180,7 @@ mod tests { fn liveness_event_once_per_transition() { let clock = MutClock::new(0); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); inbox.set_stall_threshold(a, Some(10_000)); inbox.enqueue(a, ticket(10, "t")); // last_seen=0 @@ -2159,7 +2190,10 @@ mod tests { // Sweeps répétés : pas de ré-émission. inbox.sweep_stalled(); inbox.sweep_stalled(); - assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + assert_eq!( + bus.liveness_events(), + vec![(a.agent_id, AgentLiveness::Stalled)] + ); // Battement tardif ⇒ une seule reprise Alive. clock.set(20_000); @@ -2167,7 +2201,10 @@ mod tests { inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien. assert_eq!( bus.liveness_events(), - vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + vec![ + (a.agent_id, AgentLiveness::Stalled), + (a.agent_id, AgentLiveness::Alive) + ] ); // Re-stall possible après reprise (nouvelle transition). @@ -2176,9 +2213,9 @@ mod tests { assert_eq!( bus.liveness_events(), vec![ - (a, AgentLiveness::Stalled), - (a, AgentLiveness::Alive), - (a, AgentLiveness::Stalled), + (a.agent_id, AgentLiveness::Stalled), + (a.agent_id, AgentLiveness::Alive), + (a.agent_id, AgentLiveness::Stalled), ] ); } @@ -2188,17 +2225,23 @@ mod tests { fn mark_idle_on_stalled_emits_recovery_and_clears() { let clock = MutClock::new(0); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); inbox.set_stall_threshold(a, Some(5_000)); inbox.enqueue(a, ticket(10, "t")); clock.set(5_001); inbox.sweep_stalled(); - assert_eq!(bus.liveness_events(), vec![(a, AgentLiveness::Stalled)]); + assert_eq!( + bus.liveness_events(), + vec![(a.agent_id, AgentLiveness::Stalled)] + ); inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée. assert_eq!( bus.liveness_events(), - vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + vec![ + (a.agent_id, AgentLiveness::Stalled), + (a.agent_id, AgentLiveness::Alive) + ] ); // Plus d'entrée : un sweep ultérieur ne ré-émet rien (même très tard). @@ -2206,7 +2249,10 @@ mod tests { inbox.sweep_stalled(); assert_eq!( bus.liveness_events(), - vec![(a, AgentLiveness::Stalled), (a, AgentLiveness::Alive)] + vec![ + (a.agent_id, AgentLiveness::Stalled), + (a.agent_id, AgentLiveness::Alive) + ] ); } @@ -2216,7 +2262,7 @@ mod tests { fn agent_without_threshold_is_never_stalled() { let clock = MutClock::new(0); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); // Pas de set_stall_threshold (ou None) : armé sans seuil au start_turn. inbox.enqueue(a, ticket(10, "t")); clock.set(10_000_000); // très loin dans le futur. @@ -2236,7 +2282,7 @@ mod tests { fn mark_alive_on_unarmed_agent_is_noop() { let clock = MutClock::new(0); let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); - let a = agent(1); + let a = key(1); inbox.mark_alive(a); // jamais de tour démarré. inbox.sweep_stalled(); assert_eq!(bus.liveness_events(), vec![]); diff --git a/crates/infrastructure/src/mailbox/mod.rs b/crates/infrastructure/src/mailbox/mod.rs index b0c3f6b..fda3a31 100644 --- a/crates/infrastructure/src/mailbox/mod.rs +++ b/crates/infrastructure/src/mailbox/mod.rs @@ -24,7 +24,7 @@ use std::sync::Mutex; use tokio::sync::oneshot; -use domain::ids::AgentId; +use domain::ids::RuntimeAgentKey; use domain::mailbox::{ AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket, TicketId, TurnResolution, @@ -44,7 +44,7 @@ struct Slot { /// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]). #[derive(Default)] pub struct InMemoryMailbox { - queues: Mutex>>, + queues: Mutex>>, } impl InMemoryMailbox { @@ -58,7 +58,7 @@ impl InMemoryMailbox { /// Number of tickets currently queued for `agent` (test/inspection helper). #[must_use] - pub fn pending(&self, agent: &AgentId) -> usize { + pub fn pending(&self, agent: &RuntimeAgentKey) -> usize { self.queues .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -69,7 +69,7 @@ impl InMemoryMailbox { /// The id of the ticket currently at the head of `agent`'s queue, if any /// (test/inspection helper). #[must_use] - pub fn head_ticket(&self, agent: &AgentId) -> Option { + pub fn head_ticket(&self, agent: &RuntimeAgentKey) -> Option { self.queues .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) @@ -80,7 +80,7 @@ impl InMemoryMailbox { } impl AgentMailbox for InMemoryMailbox { - fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply { + fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply { let (tx, rx) = oneshot::channel::(); let ticket_id = ticket.id; let (depth_before, depth_after) = { @@ -105,7 +105,7 @@ impl AgentMailbox for InMemoryMailbox { })) } - fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> { + fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError> { // Pop the head slot under the lock, then send **outside** any await (the send // is non-blocking). If the receiver already went away (caller timed out), the // ticket is still correctly retired from the head — the queue advances. @@ -121,7 +121,7 @@ impl AgentMailbox for InMemoryMailbox { application::diag!( "[mailbox] resolve no-pending agent={agent} (réponse orpheline ?)" ); - MailboxError::NoPendingRequest(agent) + MailboxError::NoPendingRequest(agent.agent_id) })?; let before = queue.len(); let slot = queue.pop_front().expect("non-empty queue has a head"); @@ -140,7 +140,7 @@ impl AgentMailbox for InMemoryMailbox { fn resolve_ticket( &self, - agent: AgentId, + agent: RuntimeAgentKey, ticket_id: TicketId, result: String, ) -> Result<(), MailboxError> { @@ -159,7 +159,7 @@ impl AgentMailbox for InMemoryMailbox { application::diag!( "[mailbox] resolve_ticket no-queue agent={agent} ticket={ticket_id}" ); - MailboxError::NoPendingRequest(agent) + MailboxError::NoPendingRequest(agent.agent_id) })?; let before = queue.len(); let pos = queue @@ -170,7 +170,7 @@ impl AgentMailbox for InMemoryMailbox { "[mailbox] resolve_ticket no-match agent={agent} ticket={ticket_id} \ queue_depth={before}" ); - MailboxError::NoPendingRequest(agent) + MailboxError::NoPendingRequest(agent.agent_id) })?; let slot = queue.remove(pos).expect("position just found is in range"); (slot, before, queue.len()) @@ -183,7 +183,7 @@ impl AgentMailbox for InMemoryMailbox { Ok(()) } - fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { + fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { let (depth_before, depth_after, retired) = { let mut queues = self .queues @@ -218,7 +218,7 @@ impl AgentMailbox for InMemoryMailbox { ); } - fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) { + fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) { // Head-only, idempotent, and fire-and-forget-safe (see the port contract). // `outcome` records what happened for the diag beacon: "sent" (head retired + // caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already @@ -273,7 +273,7 @@ impl AgentMailbox for InMemoryMailbox { } impl AgentQueueSnapshot for InMemoryMailbox { - fn queue_for(&self, agent: AgentId) -> Vec { + fn queue_for(&self, agent: RuntimeAgentKey) -> Vec { // Pure read: clone each ticket's *data* (never the `oneshot::Sender`) under // the lock, recomputing the FIFO position from the current order (0 = head). // The queue is observed, not mutated. @@ -304,11 +304,19 @@ impl AgentQueueSnapshot for InMemoryMailbox { #[cfg(test)] mod tests { use super::*; + use domain::AgentId; fn agent(n: u128) -> AgentId { AgentId::from_uuid(uuid::Uuid::from_u128(n)) } + fn key(n: u128) -> RuntimeAgentKey { + RuntimeAgentKey::new( + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(1000 + n)), + agent(n), + ) + } + fn ticket(n: u128, task: &str) -> Ticket { Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task) } @@ -316,7 +324,7 @@ mod tests { #[tokio::test] async fn enqueue_then_resolve_wakes_the_pending_reply() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let pending = mb.enqueue(a, ticket(10, "do X")); mb.resolve(a, "done X".to_owned()).expect("resolve ok"); @@ -329,7 +337,7 @@ mod tests { #[tokio::test] async fn two_asks_same_target_resolve_fifo_head_first() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let p1 = mb.enqueue(a, ticket(10, "first")); let p2 = mb.enqueue(a, ticket(11, "second")); assert_eq!(mb.pending(&a), 2); @@ -353,8 +361,8 @@ mod tests { #[tokio::test] async fn different_targets_do_not_block_each_other() { let mb = InMemoryMailbox::new(); - let a = agent(1); - let b = agent(2); + let a = key(1); + let b = key(2); let pa = mb.enqueue(a, ticket(10, "task a")); let _pb = mb.enqueue(b, ticket(20, "task b")); @@ -368,17 +376,17 @@ mod tests { #[test] fn resolve_without_pending_is_a_typed_error() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); assert_eq!( mb.resolve(a, "orphan".to_owned()), - Err(MailboxError::NoPendingRequest(a)) + Err(MailboxError::NoPendingRequest(a.agent_id)) ); } #[tokio::test] async fn cancel_head_retires_the_head_and_advances_the_queue() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let p1 = mb.enqueue(a, ticket(10, "stuck")); let p2 = mb.enqueue(a, ticket(11, "next")); @@ -401,7 +409,7 @@ mod tests { #[tokio::test] async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let pending = mb.enqueue(a, ticket(10, "awaited")); // The target returned to its prompt without idea_reply ⇒ the awaiting caller @@ -417,7 +425,7 @@ mod tests { #[tokio::test] async fn complete_without_reply_is_noop_when_receiver_already_gone() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); // Human fire-and-forget: the PendingReply is dropped immediately (not awaited). drop(mb.enqueue(a, ticket(10, "human submit"))); @@ -434,7 +442,7 @@ mod tests { #[tokio::test] async fn complete_without_reply_is_noop_when_not_head_and_idempotent() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let p1 = mb.enqueue(a, ticket(10, "head")); let _p2 = mb.enqueue(a, ticket(11, "behind")); @@ -455,7 +463,7 @@ mod tests { #[tokio::test] async fn reply_before_completion_wins_then_completion_is_noop() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let pending = mb.enqueue(a, ticket(10, "raced")); mb.resolve(a, "real answer".to_owned()).unwrap(); @@ -472,7 +480,7 @@ mod tests { #[test] fn cancel_head_is_a_noop_when_head_is_a_different_ticket() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let _p1 = mb.enqueue(a, ticket(10, "head")); // Try to cancel a ticket that is NOT the head ⇒ nothing retired. mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99))); @@ -490,13 +498,13 @@ mod tests { #[test] fn snapshot_of_empty_queue_is_empty() { let mb = InMemoryMailbox::new(); - assert!(mb.queue_for(agent(1)).is_empty()); + assert!(mb.queue_for(key(1)).is_empty()); } #[test] fn snapshot_preserves_fifo_order_and_positions() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let _p1 = mb.enqueue(a, ticket(10, "first")); let _p2 = mb.enqueue(a, ticket(11, "second")); @@ -516,7 +524,7 @@ mod tests { use domain::input::InputSource; let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let from = agent(2); let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42)); let _p = mb.enqueue( @@ -535,7 +543,7 @@ mod tests { #[test] fn snapshot_is_read_only() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let _p1 = mb.enqueue(a, ticket(10, "first")); let _p2 = mb.enqueue(a, ticket(11, "second")); @@ -550,7 +558,7 @@ mod tests { #[test] fn snapshot_updates_after_resolve_ticket() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let _p1 = mb.enqueue(a, ticket(10, "first")); let _p2 = mb.enqueue(a, ticket(11, "second")); @@ -572,7 +580,7 @@ mod tests { application::diag::set_log_path(path.clone()); let mb = InMemoryMailbox::new(); - let a = agent(777_001); + let a = key(777_001); let t = tid(777_010); let _p = mb.enqueue(a, Ticket::new(t, "Main", "diag task")); mb.resolve(a, "done".to_owned()).expect("resolve ok"); @@ -596,7 +604,7 @@ mod tests { #[test] fn snapshot_updates_after_cancel_head() { let mb = InMemoryMailbox::new(); - let a = agent(1); + let a = key(1); let _p1 = mb.enqueue(a, ticket(10, "head")); let _p2 = mb.enqueue(a, ticket(11, "next")); diff --git a/crates/infrastructure/src/plugin/mod.rs b/crates/infrastructure/src/plugin/mod.rs index f7ac343..b82be10 100644 --- a/crates/infrastructure/src/plugin/mod.rs +++ b/crates/infrastructure/src/plugin/mod.rs @@ -265,9 +265,7 @@ impl PluginPackageStore for FsPluginPackageStore { } fn app_data_dir_label(&self) -> Option { - self.root - .parent() - .map(|p| p.to_string_lossy().into_owned()) + self.root.parent().map(|p| p.to_string_lossy().into_owned()) } } diff --git a/crates/infrastructure/src/scheduler/mod.rs b/crates/infrastructure/src/scheduler/mod.rs index b78a46d..301c678 100644 --- a/crates/infrastructure/src/scheduler/mod.rs +++ b/crates/infrastructure/src/scheduler/mod.rs @@ -140,6 +140,7 @@ mod tests { /// tâches concurrentes à l'arrivée). fn task_with(conv: &str) -> ScheduledTask { ScheduledTask::ResumeAgent { + project_id: domain::ProjectId::from_uuid(Uuid::nil()), agent_id: AgentId::from_uuid(Uuid::from_u128(1)), node_id: NodeId::from_uuid(Uuid::from_u128(2)), conversation_id: Some(conv.to_owned()), diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 1b4e405..b796cd4 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -396,8 +396,7 @@ mod tests { #[test] fn parse_jsonl_event_error_accepts_raw_string() { - let event = - parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap(); + let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap(); assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned())); } } diff --git a/crates/infrastructure/src/store/profile.rs b/crates/infrastructure/src/store/profile.rs index 77c2828..2be847f 100644 --- a/crates/infrastructure/src/store/profile.rs +++ b/crates/infrastructure/src/store/profile.rs @@ -90,8 +90,8 @@ impl FsProfileStore { async fn read_doc(&self) -> Result { match self.fs.read(&self.path()).await { Ok(bytes) => { - let mut doc: ProfilesDoc = - serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; + let mut doc: ProfilesDoc = serde_json::from_slice(&bytes) + .map_err(|e| StoreError::Serialization(e.to_string()))?; for profile in &mut doc.profiles { if !profile.opencode_backend_is_consistent() { profile.opencode = None; diff --git a/crates/infrastructure/src/store/secrets.rs b/crates/infrastructure/src/store/secrets.rs index 9e484ca..c74b557 100644 --- a/crates/infrastructure/src/store/secrets.rs +++ b/crates/infrastructure/src/store/secrets.rs @@ -99,10 +99,11 @@ impl FsSecretStore { } Err(FsError::NotFound(_)) => { let mut key = [0_u8; KEY_LEN]; - SystemRandom::new() - .fill(&mut key) - .map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?; - let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + SystemRandom::new().fill(&mut key).map_err(|_| { + SecretStoreError::Crypto("failed to generate secret key".into()) + })?; + let dir = + RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); self.fs .create_dir_all(&dir) .await @@ -228,7 +229,10 @@ mod tests { impl TempDir { fn new(label: &str) -> Self { - let root = std::env::temp_dir().join(format!("idea-secrets-store-{label}-{}", uuid::Uuid::new_v4())); + let root = std::env::temp_dir().join(format!( + "idea-secrets-store-{label}-{}", + uuid::Uuid::new_v4() + )); std::fs::create_dir_all(&root).unwrap(); Self(root) } @@ -293,7 +297,10 @@ mod tests { async fn key_file_has_owner_only_permissions_on_unix() { let dir = TempDir::new("perms"); let store = store(&dir); - store.put(&SecretRef::new("secret-d"), "value").await.unwrap(); + store + .put(&SecretRef::new("secret-d"), "value") + .await + .unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; diff --git a/crates/infrastructure/tests/agent_inbox.rs b/crates/infrastructure/tests/agent_inbox.rs index a0bd73f..3fe8775 100644 --- a/crates/infrastructure/tests/agent_inbox.rs +++ b/crates/infrastructure/tests/agent_inbox.rs @@ -5,7 +5,7 @@ use std::time::Duration; use domain::{ AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, - InputMediator, Ticket, TicketId, + InputMediator, ProjectId, RuntimeAgentKey, Ticket, TicketId, }; use infrastructure::{ start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox, @@ -26,6 +26,14 @@ fn agent(n: u128) -> AgentId { AgentId::from_uuid(Uuid::from_u128(n)) } +fn project_id(n: u128) -> ProjectId { + ProjectId::from_uuid(Uuid::from_u128(n)) +} + +fn key(project: u128, target: AgentId) -> RuntimeAgentKey { + RuntimeAgentKey::new(project_id(project), target) +} + fn ticket_id(n: u128) -> TicketId { TicketId::from_uuid(Uuid::from_u128(n)) } @@ -70,7 +78,7 @@ fn completion_item(id: u128, target: AgentId, task: u128) -> InboxItem { fn make_busy(inbox: &MediatedInbox, target: AgentId) { let ticket = Ticket::new(ticket_id(900), "active", "active turn"); - let _pending = inbox.enqueue(target, ticket); + let _pending = inbox.enqueue(key(1, target), ticket); } #[test] @@ -80,12 +88,12 @@ fn enqueue_message_while_busy_is_queued_not_rejected() { make_busy(&inbox, target); let receipt = inbox - .enqueue_message(target, user_item(1, target, "queued")) + .enqueue_message(key(1, target), user_item(1, target, "queued")) .unwrap(); assert_eq!(receipt.status, InboxReceiptStatus::Queued); assert_eq!(receipt.depth, 2); - let snapshot = inbox.snapshot(target); + let snapshot = inbox.snapshot(key(1, target)); assert_eq!(snapshot.depth, 2); assert_eq!(snapshot.items.len(), 1); assert_eq!(snapshot.items[0].body, "queued"); @@ -98,12 +106,16 @@ fn inbox_fifo_drains_two_entrants_in_order() { let first = user_item(1, target, "first"); let second = user_item(2, target, "second"); - inbox.enqueue_message(target, first.clone()).unwrap(); - inbox.enqueue_message(target, second.clone()).unwrap(); + inbox + .enqueue_message(key(1, target), first.clone()) + .unwrap(); + inbox + .enqueue_message(key(1, target), second.clone()) + .unwrap(); - assert_eq!(inbox.dequeue_next(target), Some(first)); - assert_eq!(inbox.dequeue_next(target), Some(second)); - assert_eq!(inbox.dequeue_next(target), None); + assert_eq!(inbox.dequeue_next(key(1, target)), Some(first)); + assert_eq!(inbox.dequeue_next(key(1, target)), Some(second)); + assert_eq!(inbox.dequeue_next(key(1, target)), None); } #[test] @@ -113,7 +125,7 @@ fn overflow_normal_message_returns_inbox_full() { make_busy(&inbox, target); let err = inbox - .enqueue_message(target, user_item(1, target, "overflow")) + .enqueue_message(key(1, target), user_item(1, target, "overflow")) .unwrap_err(); assert_eq!( @@ -132,12 +144,12 @@ fn overflow_completion_is_deferred_not_lost_or_enqueued() { make_busy(&inbox, target); let receipt = inbox - .enqueue_message(target, completion_item(1, target, 42)) + .enqueue_message(key(1, target), completion_item(1, target, 42)) .unwrap(); assert_eq!(receipt.status, InboxReceiptStatus::Deferred); assert_eq!(receipt.depth, 1); - assert!(inbox.snapshot(target).items.is_empty()); + assert!(inbox.snapshot(key(1, target)).items.is_empty()); } #[test] @@ -146,13 +158,13 @@ fn snapshot_exposes_queue_depth_and_items() { let target = agent(1); inbox - .enqueue_message(target, user_item(1, target, "first")) + .enqueue_message(key(1, target), user_item(1, target, "first")) .unwrap(); inbox - .enqueue_message(target, user_item(2, target, "second")) + .enqueue_message(key(1, target), user_item(2, target, "second")) .unwrap(); - let snapshot = inbox.snapshot(target); + let snapshot = inbox.snapshot(key(1, target)); assert_eq!(snapshot.agent_id, target); assert_eq!(snapshot.depth, 2); assert_eq!( @@ -170,19 +182,38 @@ fn drain_removes_only_one_item_at_a_time() { let inbox = inbox(10); let target = agent(1); inbox - .enqueue_message(target, user_item(1, target, "first")) + .enqueue_message(key(1, target), user_item(1, target, "first")) .unwrap(); inbox - .enqueue_message(target, user_item(2, target, "second")) + .enqueue_message(key(1, target), user_item(2, target, "second")) .unwrap(); - assert_eq!(inbox.dequeue_next(target).unwrap().body, "first"); + assert_eq!(inbox.dequeue_next(key(1, target)).unwrap().body, "first"); - let snapshot = inbox.snapshot(target); + let snapshot = inbox.snapshot(key(1, target)); assert_eq!(snapshot.depth, 1); assert_eq!(snapshot.items[0].body, "second"); } +#[test] +fn same_agent_id_in_distinct_projects_has_isolated_inbox_queues() { + let inbox = inbox(10); + let target = agent(1); + let p1 = key(1, target); + let p2 = key(2, target); + let first = user_item(1, target, "project one"); + let second = user_item(2, target, "project two"); + + inbox.enqueue_message(p1, first.clone()).unwrap(); + inbox.enqueue_message(p2, second.clone()).unwrap(); + + assert_eq!(inbox.snapshot(p1).depth, 1); + assert_eq!(inbox.snapshot(p2).depth, 1); + assert_eq!(inbox.dequeue_next(p1), Some(first)); + assert_eq!(inbox.snapshot(p1).depth, 0); + assert_eq!(inbox.snapshot(p2).items, vec![second]); +} + #[tokio::test] async fn ready_bridge_enqueues_background_completion_item() { let inbox = Arc::new(inbox(10)); @@ -192,13 +223,13 @@ async fn ready_bridge_enqueues_background_completion_item() { tx.send(BackgroundTaskReadyToDeliver { task_id: task_id(42), - project_id: domain::ProjectId::from_uuid(Uuid::from_u128(7)), + project_id: project_id(7), owner_agent_id: target, }) .unwrap(); tokio::time::sleep(Duration::from_millis(20)).await; - let snapshot = inbox.snapshot(target); + let snapshot = inbox.snapshot(key(7, target)); assert_eq!(snapshot.depth, 1); assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion); assert_eq!( diff --git a/crates/infrastructure/tests/mcp_server.rs b/crates/infrastructure/tests/mcp_server.rs index b9e2c9a..7bbe397 100644 --- a/crates/infrastructure/tests/mcp_server.rs +++ b/crates/infrastructure/tests/mcp_server.rs @@ -1146,7 +1146,8 @@ async fn stop_agent_call_closes_the_live_session() { let (service, sessions) = build_service(contexts); // Pre-bind a live PTY session for the agent so StopAgent has something to close. let session_id = SessionId::from_uuid(Uuid::from_u128(555)); - sessions.insert( + sessions.insert_in_project( + project().id, PtyHandle { session_id }, TerminalSession::starting( session_id, @@ -1156,7 +1157,9 @@ async fn stop_agent_call_closes_the_live_session() { PtySize { rows: 24, cols: 80 }, ), ); - assert!(sessions.session_for_agent(&agent_id).is_some()); + assert!(sessions + .session_for_agent_in_project(project().id, &agent_id) + .is_some()); let server = server(service); let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" })); @@ -1166,7 +1169,9 @@ async fn stop_agent_call_closes_the_live_session() { // CloseTerminal ran → the agent no longer has a live session. assert!( - sessions.session_for_agent(&agent_id).is_none(), + sessions + .session_for_agent_in_project(project().id, &agent_id) + .is_none(), "stop_agent should have removed the session" ); } diff --git a/crates/infrastructure/tests/orchestrator_watcher.rs b/crates/infrastructure/tests/orchestrator_watcher.rs index 04d6414..c8cc82e 100644 --- a/crates/infrastructure/tests/orchestrator_watcher.rs +++ b/crates/infrastructure/tests/orchestrator_watcher.rs @@ -17,7 +17,7 @@ use async_trait::async_trait; use domain::agent::{AgentManifest, ManifestEntry}; use domain::events::{DomainEvent, OrchestrationSource}; use domain::ids::SkillId; -use domain::ids::{AgentId, ProfileId, ProjectId}; +use domain::ids::{AgentId, ProfileId, ProjectId, RuntimeAgentKey}; use domain::markdown::MarkdownDoc; use domain::ports::{ AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, @@ -840,12 +840,13 @@ async fn ask_request_surfaces_reply_alongside_detail() { let svc = Arc::clone(&service); let proj = project(); + let runtime_key = RuntimeAgentKey::new(proj.id, agent_id); let ask_req = req.clone(); let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); // Wait until the ask has enqueued its ticket (blocked awaiting the reply). tokio::time::timeout(std::time::Duration::from_secs(10), async { - while mailbox.pending(&agent_id) == 0 { + while mailbox.pending(&runtime_key) == 0 { tokio::task::yield_now().await; } }) @@ -918,11 +919,12 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() { let svc = Arc::clone(&service); let proj = project(); + let runtime_key = RuntimeAgentKey::new(proj.id, agent_id); let ask_req = req.clone(); let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); tokio::time::timeout(std::time::Duration::from_secs(10), async { - while mailbox.pending(&agent_id) == 0 { + while mailbox.pending(&runtime_key) == 0 { tokio::task::yield_now().await; } }) @@ -951,7 +953,11 @@ async fn ask_request_no_reply_persists_failed_rendezvous_task() { } other => panic!("expected failure result, got {other:?}"), } - assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed"); + assert_eq!( + mailbox.pending(&runtime_key), + 0, + "turn-lock mailbox is freed" + ); } /// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply` diff --git a/crates/infrastructure/tests/profile_store.rs b/crates/infrastructure/tests/profile_store.rs index e0c191e..a138779 100644 --- a/crates/infrastructure/tests/profile_store.rs +++ b/crates/infrastructure/tests/profile_store.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use domain::ids::ProfileId; use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError}; -use domain::profile::{AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter}; +use domain::profile::{ + AgentProfile, ContextInjection, OpenCodeConfig, OpenCodeProviderConfig, StructuredAdapter, +}; use infrastructure::{FsProfileStore, LocalFileSystem}; use uuid::Uuid; @@ -173,11 +175,20 @@ async fn profiles_file_is_camelcase_versioned() { /// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode /// backends. A profile carrying both is inconsistent. fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) { - let local = - OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap(); - let cloud = - OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud")) - .unwrap(); + let local = OpenCodeConfig::new( + "http://localhost:8080/v1", + None, + "qwen3-coder-30b", + None, + None, + ) + .unwrap(); + let cloud = OpenCodeProviderConfig::new( + "anthropic", + "claude-sonnet-5", + SecretRef::new("secret-cloud"), + ) + .unwrap(); (local, cloud) } @@ -203,15 +214,21 @@ async fn read_doc_repairs_stale_opencode_when_both_sections_present() { let fs = LocalFileSystem::new(); let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] }); - fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap()) - .await - .unwrap(); + fs.write( + &tmp.child("profiles.json"), + &serde_json::to_vec_pretty(&doc).unwrap(), + ) + .await + .unwrap(); let store = store(&tmp); let listed = store.list().await.expect("read repairs instead of failing"); assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry"); let repaired = &listed[0]; - assert!(repaired.opencode.is_none(), "stale local `opencode` dropped on read"); + assert!( + repaired.opencode.is_none(), + "stale local `opencode` dropped on read" + ); assert_eq!( repaired.opencode_provider.as_ref().unwrap().provider_id, "anthropic", diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 148ea1a..7d05407 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -1458,6 +1458,7 @@ async fn execute_launch_agent_for_ws( request: LaunchAgentRequestDto, ) -> Result { let project = resolve_project_readonly(&request.project_id, &state.app).await?; + let project_id = project.id; let agent_id = parse_agent_id(&request.agent_id)?; let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime { @@ -1492,7 +1493,7 @@ async fn execute_launch_agent_for_ws( if let Ok(mut contexts) = state.app.resume_contexts.lock() { contexts.insert( - agent_id, + domain::RuntimeAgentKey::new(project_id, agent_id), backend::ResumeContext { project: resume_project, rows: request.rows, @@ -1503,6 +1504,7 @@ async fn execute_launch_agent_for_ws( if let Some(profile) = output.profile.as_ref() { state.app.arm_turn_watch( + project_id, &watch_root, agent_id, profile, @@ -6067,8 +6069,13 @@ mod tests { scrollback ); let aid = parse_agent_id(&agent_id).unwrap(); + let pid = parse_project_id(&project_id).unwrap(); assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), + state + .app + .terminal_sessions + .sessions_for_agent_in_project(pid, &aid) + .len(), 1 ); @@ -6122,8 +6129,13 @@ mod tests { assert_eq!(second.kind, "terminal.attached"); assert_eq!(attached_session_id(&second), first_session); let aid = parse_agent_id(&agent_id).unwrap(); + let pid = parse_project_id(&project_id).unwrap(); assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), + state + .app + .terminal_sessions + .sessions_for_agent_in_project(pid, &aid) + .len(), 1 ); @@ -6174,8 +6186,13 @@ mod tests { assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); let aid = parse_agent_id(&agent_id).unwrap(); + let pid = parse_project_id(&project_id).unwrap(); assert_eq!( - state.app.terminal_sessions.sessions_for_agent(&aid).len(), + state + .app + .terminal_sessions + .sessions_for_agent_in_project(pid, &aid) + .len(), 1 );