merge: integrate ticket 101 multi-project isolation

This commit is contained in:
2026-07-25 22:14:19 +02:00
52 changed files with 1894 additions and 805 deletions

View File

@ -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).

View File

@ -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).

View File

@ -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)

View File

@ -52,13 +52,12 @@ use crate::dto::{
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, ModelServerConfigDto,
ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto, ModelServerConfigListDto, OpenCodeProviderListDto, OpenTerminalRequestDto,
PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto, PreviewModelServerCommandDto, ProfileDto, ProfileListDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectWorkStateDto,
ProjectPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto, ReadAgentContextResponseDto, ReadConversationPageRequestDto, ReattachChatDto,
ReadConversationPageRequestDto, ReattachChatDto, ReattachResultDto, RecallMemoryRequestDto, ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
RenameLayoutRequestDto, ReplyChunk, ResizeTerminalRequestDto, ResizeTerminalRequestDto, ResolveAgentPermissionsRequestDto, ResumableAgentListDto,
ResolveAgentPermissionsRequestDto, ResumableAgentListDto, SaveEmbedderProfileRequestDto, SaveEmbedderProfileRequestDto, SaveModelServerRequestDto,
SaveModelServerRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SaveOpenCodeProviderProfileRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto, SetActiveLayoutResultDto, SkillDto, SkillListDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto, StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalClosedDto, TerminalSessionDto, TurnPageDto, UnassignSkillRequestDto, 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 /// `Arc` registries already held by [`AppState`]; the aggregation logic itself
/// is not duplicated. /// is not duplicated.
/// ///
/// `project_id` is accepted for API symmetry and future per-project scoping; the /// `project_id` scopes the process-wide runtime registry before serialization so two
/// session registry is process-wide today, so the full live set is returned (a /// open projects carrying the same persisted `AgentId` cannot see each other's live
/// project's agent ids are disjoint from other projects' by construction, so the /// session.
/// frontend can filter by the agents it knows).
/// ///
/// # Errors /// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed project id). /// Returns an [`ErrorDto`] (`INVALID` for a malformed project id).
@ -1561,13 +1559,16 @@ pub fn list_live_agents(
) -> Result<LiveAgentListDto, ErrorDto> { ) -> Result<LiveAgentListDto, ErrorDto> {
// Validate the id shape for a consistent contract, even though the registry // Validate the id shape for a consistent contract, even though the registry
// is not project-scoped yet. // is not project-scoped yet.
let _ = parse_project_id(&project_id)?; let project_id = parse_project_id(&project_id)?;
let live = LiveSessions::new( let live = LiveSessions::new(
std::sync::Arc::clone(&state.terminal_sessions), std::sync::Arc::clone(&state.terminal_sessions),
std::sync::Arc::clone(&state.structured_sessions), std::sync::Arc::clone(&state.structured_sessions),
); );
Ok(LiveAgentListDto::from_snapshots( 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); .active_wait_dependencies(agent_id);
// Backstop no-reply : arrêter l'observateur de fin de tour de l'agent (le handle est // 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. // 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 { for dependency in dependencies {
state.stop_turn_watch(dependency); state.stop_turn_watch(project.id, dependency);
} }
state state
.stop_live_agent .stop_live_agent
@ -1745,6 +1746,7 @@ pub async fn launch_agent(
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<TerminalSessionDto, ErrorDto> { ) -> Result<TerminalSessionDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?; let project = resolve_project(&request.project_id, &state).await?;
let project_id = project.id;
let agent_id = parse_agent_id(&request.agent_id)?; let agent_id = parse_agent_id(&request.agent_id)?;
// The hosting cell drives the singleton-invariant guard. Parse it when the // The hosting cell drives the singleton-invariant guard. Parse it when the
// frontend supplies one; absent ⇒ `None` (a fresh node is minted, and an // 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() { if let Ok(mut contexts) = state.resume_contexts.lock() {
contexts.insert( contexts.insert(
agent_id, domain::RuntimeAgentKey::new(project_id, agent_id),
crate::state::ResumeContext { crate::state::ResumeContext {
project: resume_project, project: resume_project,
rows: request.rows, rows: request.rows,
@ -1819,6 +1821,7 @@ pub async fn launch_agent(
// posé au 1er lancement persiste. cwd = run dir isolé de l'agent. // posé au 1er lancement persiste. cwd = run dir isolé de l'agent.
if let Some(profile) = output.profile.as_ref() { if let Some(profile) = output.profile.as_ref() {
state.arm_turn_watch( state.arm_turn_watch(
project_id,
&watch_root, &watch_root,
agent_id, agent_id,
profile, profile,
@ -1900,6 +1903,7 @@ pub async fn launch_agent(
.detect(&text, domain::ports::Clock::now_millis(&*detect_clock)) .detect(&text, domain::ports::Clock::now_millis(&*detect_clock))
{ {
service.on_rate_limited( service.on_rate_limited(
project_id,
agent_id, agent_id,
host_node_id, host_node_id,
conversation_id.clone(), 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 // (le badge UI vient du bus `AgentRateLimited`, pas du flux chat) puis on
// continue à drainer comme pour un battement. // continue à drainer comme pour un battement.
if let domain::ports::ReplyEvent::RateLimited { resets_at_ms } = &event { 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) { let (conversation_id, resets_at_ms) = match (conversation_id, resets_at_ms) {
(Some(conversation_id), resets_at_ms) => { (Some(conversation_id), resets_at_ms) => {
(Some(conversation_id.clone()), *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. // une reprise non reprenable ; surfacer le fallback humain.
(None, Some(_)) => (None, None), (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 // Heartbeats carry no chat content (readiness/heartbeat lot 1) ⇒ no wire
@ -2110,28 +2120,47 @@ pub async fn set_resume_at(
) -> Result<(), ErrorDto> { ) -> Result<(), ErrorDto> {
let id = parse_agent_id(&agent_id)?; let id = parse_agent_id(&agent_id)?;
// Résolution agent→cellule : la registry des sessions vivantes est la source de // Résolution agent→(projet, cellule) : la registry des sessions vivantes est la
// vérité. On regarde d'abord le structuré (qui porte aussi le `conversation_id`), // source de vérité. Sans `project_id` dans le DTO historique, on exige un match
// puis le terminal (PTY). // unique pour éviter d'armer la reprise d'un homonyme dans le mauvais projet.
let node_id = state let live = LiveSessions::new(
.structured_sessions std::sync::Arc::clone(&state.terminal_sessions),
.node_for_agent(&id) std::sync::Arc::clone(&state.structured_sessions),
.or_else(|| state.terminal_sessions.node_for_agent(&id)) );
.ok_or_else(|| { let matches = live
ErrorDto::from(AppError::NotFound(format!( .live_agent_snapshots()
.into_iter()
.filter(|snapshot| snapshot.agent_id == id)
.collect::<Vec<_>>();
let snapshot = match matches.as_slice() {
[snapshot] => snapshot,
[] => {
return Err(ErrorDto::from(AppError::NotFound(format!(
"aucune cellule vivante pour l'agent {id}" "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. // `conversation_id` best-effort : seule une session structurée vivante l'expose.
let conversation_id = state let conversation_id = state
.structured_sessions .structured_sessions
.session_for_agent(&id) .session_for_agent_in_project(project_id, &id)
.and_then(|s| s.conversation_id()); .and_then(|s| s.conversation_id());
state state.session_limit_service.confirm_human_resume(
.session_limit_service project_id,
.confirm_human_resume(id, node_id, conversation_id, resets_at_ms); id,
node_id,
conversation_id,
resets_at_ms,
);
Ok(()) Ok(())
} }
@ -2210,9 +2239,39 @@ pub async fn set_front_attached(
"[delivery] set_front_attached command: agent={agent_id} attached={}", "[delivery] set_front_attached command: agent={agent_id} attached={}",
request.attached request.attached
); );
state let mut matches = Vec::new();
.orchestrator_service for project in state
.set_agent_front_attached(agent_id, request.attached); .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(()) Ok(())
} }

View File

@ -188,6 +188,7 @@ fn live_agent_list_dto_serialises_camelcase_array() {
let node_a = NodeId::from_uuid(Uuid::from_u128(21)); let node_a = NodeId::from_uuid(Uuid::from_u128(21));
let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31)); let session_a = domain::SessionId::from_uuid(Uuid::from_u128(31));
let dto = LiveAgentListDto::from_snapshots(vec![LiveSessionSnapshot { let dto = LiveAgentListDto::from_snapshots(vec![LiveSessionSnapshot {
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(1)),
agent_id: agent_a, agent_id: agent_a,
node_id: node_a, node_id: node_a,
session_id: session_a, session_id: session_a,

View File

@ -17,7 +17,7 @@ use std::time::Duration;
use app_tauri_lib::state::AppState; use app_tauri_lib::state::AppState;
use async_trait::async_trait; use async_trait::async_trait;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::{NodeId, SessionId}; use domain::ids::{NodeId, ProjectId, SessionId};
use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream}; use domain::ports::{AgentSession, AgentSessionError, IdGenerator, ReplyStream};
use domain::AgentId; use domain::AgentId;
use infrastructure::UuidGenerator; use infrastructure::UuidGenerator;
@ -35,6 +35,10 @@ fn node(n: u128) -> NodeId {
NodeId::from_uuid(uuid::Uuid::from_u128(n)) 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 { fn session(n: u128) -> SessionId {
SessionId::from_uuid(uuid::Uuid::from_u128(n)) 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. // Reset far in the future so the scheduler does NOT fire before we cancel.
let resets_at_ms = i64::MAX; let resets_at_ms = i64::MAX;
state state.session_limit_service.on_rate_limited(
.session_limit_service project_id(1),
.on_rate_limited(agent(7), node(70), None, Some(resets_at_ms)); agent(7),
node(70),
None,
Some(resets_at_ms),
);
// The two arming events, in order, on the bus. // The two arming events, in order, on the bus.
let mut saw_rate_limited = false; let mut saw_rate_limited = false;
@ -145,7 +153,7 @@ async fn direct_structured_rate_limit_uses_live_meta_and_arms_resume() {
node(70), node(70),
); );
let (agent_id, node_id, conversation_id) = state let (project_id, agent_id, node_id, conversation_id) = state
.structured_sessions .structured_sessions
.meta_for_session(&sid) .meta_for_session(&sid)
.expect("live structured session metadata"); .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; let resets_at_ms = i64::MAX;
state.session_limit_service.on_rate_limited( state.session_limit_service.on_rate_limited(
project_id,
agent_id, agent_id,
node_id, node_id,
conversation_id, conversation_id,
@ -205,7 +214,7 @@ async fn direct_structured_rate_limit_without_conversation_uses_human_fallback()
node(70), node(70),
); );
let (agent_id, node_id, conversation_id) = state let (project_id, agent_id, node_id, conversation_id) = state
.structured_sessions .structured_sessions
.meta_for_session(&sid) .meta_for_session(&sid)
.expect("live structured session metadata"); .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, None) => (None, None),
(None, Some(_)) => (None, None), (None, Some(_)) => (None, None),
}; };
state state.session_limit_service.on_rate_limited(
.session_limit_service project_id,
.on_rate_limited(agent_id, node_id, conversation_id, resets_at_ms); agent_id,
node_id,
conversation_id,
resets_at_ms,
);
let mut saw_rate_limited_none = false; let mut saw_rate_limited_none = false;
let mut saw_suspected = 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. // Reset très loin dans le futur : le scheduler ne tire pas avant l'annulation.
let resets_at_ms = i64::MAX; let resets_at_ms = i64::MAX;
state state.session_limit_service.confirm_human_resume(
.session_limit_service project_id(1),
.confirm_human_resume(agent(8), node(80), None, resets_at_ms); agent(8),
node(80),
None,
resets_at_ms,
);
let mut saw_rate_limited = false; let mut saw_rate_limited = false;
let mut saw_scheduled = false; let mut saw_scheduled = false;

View File

@ -724,7 +724,9 @@ impl ChangeAgentProfile {
// Résolution **polymorphe** de la session vivante sur les deux registres // 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 // (§17.4) : structuré d'abord, puis PTY. Un agent ne vit que dans un seul des
// deux à la fois (invariant « 1 session/agent »). // 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 { let Some(node_id) = killed else {
// Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer. // Aucune session vivante (ni structurée, ni PTY) ⇒ rien à relancer.
return Ok(None); return Ok(None);
@ -776,12 +778,15 @@ impl ChangeAgentProfile {
/// node neuf côté relance via `LaunchAgentInput.node_id = None`. /// node neuf côté relance via `LaunchAgentInput.node_id = None`.
async fn kill_live_session( async fn kill_live_session(
&self, &self,
project: &Project,
agent_id: &AgentId, agent_id: &AgentId,
) -> Result<Option<Option<NodeId>>, AppError> { ) -> Result<Option<Option<NodeId>>, AppError> {
// 1. Session structurée vivante ? ⇒ shutdown polymorphe. // 1. Session structurée vivante ? ⇒ shutdown polymorphe.
if let Some(structured) = &self.structured { if let Some(structured) = &self.structured {
if let Some(session_id) = structured.session_id_for_agent(agent_id) { if let Some(session_id) =
let node_id = structured.node_for_agent(agent_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) { if let Some(session) = structured.remove(&session_id) {
session session
.shutdown() .shutdown()
@ -793,10 +798,15 @@ impl ChangeAgentProfile {
} }
// 2. Sinon, session PTY vivante ? ⇒ kill PTY (chemin historique). // 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); 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) { if let Some(handle) = self.sessions.remove(&session_id) {
self.pty.kill(&handle).await?; self.pty.kill(&handle).await?;
} }
@ -1506,9 +1516,13 @@ impl LaunchAgent {
// (rend la session existante, pas de respawn) ; // (rend la session existante, pas de respawn) ;
// - **second lancement neuf** : on vise un **autre** node, sans signal de // - **second lancement neuf** : on vise un **autre** node, sans signal de
// réattache ⇒ refus [`AppError::AgentAlreadyRunning`] (node hôte rapporté). // 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 { 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() { if input.allow_structured_alongside_pty && input.node_id.is_none() {
crate::diag!( crate::diag!(
"[launch] existing PTY kept while structured launch proceeds: agent={} \ "[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()) match reattach_decision(input.node_id, host_node, input.conversation_id.as_deref())
{ {
ReattachDecision::Rebind { node_id } => { ReattachDecision::Rebind { node_id } => {
if let Some(session) = if let Some(session) = self.sessions.rebind_agent_node_in_project(
self.sessions.rebind_agent_node(&input.agent_id, node_id) input.project.id,
{ &input.agent_id,
node_id,
) {
return Ok(LaunchAgentOutput { return Ok(LaunchAgentOutput {
session, session,
assigned_conversation_id: None, assigned_conversation_id: None,
@ -1558,15 +1574,22 @@ impl LaunchAgent {
// façon identique — rebind de la cellule-vue pour une réattache légitime, // 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. // idempotence sans node/conversation, refus d'un second lancement neuf ailleurs.
if let Some(structured) = &self.structured { if let Some(structured) = &self.structured {
if let Some(existing) = structured.session_for_agent(&input.agent_id) { if let Some(existing) =
let host_node = structured.node_for_agent(&input.agent_id); 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( let node_id = match reattach_decision(
input.node_id, input.node_id,
host_node, host_node,
input.conversation_id.as_deref(), input.conversation_id.as_deref(),
) { ) {
ReattachDecision::Rebind { node_id } => { 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 node_id
} }
// Idempotent — garder le node hôte courant, sinon un node neuf. // 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 — // 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. // qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| { let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair( ConversationId::for_project_pair(
input.project.id,
ConversationParty::User, ConversationParty::User,
ConversationParty::agent(agent.id), ConversationParty::agent(agent.id),
) )
@ -1788,6 +1812,7 @@ impl LaunchAgent {
&run_dir, &run_dir,
&session_plan, &session_plan,
pair_conversation_id, pair_conversation_id,
input.project.id,
&input.project.root, &input.project.root,
input.node_id, input.node_id,
size, size,
@ -1824,7 +1849,8 @@ impl LaunchAgent {
size, size,
); );
session.status = SessionStatus::Running; 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 { self.events.publish(DomainEvent::AgentLaunched {
agent_id: agent.id, agent_id: agent.id,
@ -1863,6 +1889,7 @@ impl LaunchAgent {
run_dir: &ProjectPath, run_dir: &ProjectPath,
session_plan: &SessionPlan, session_plan: &SessionPlan,
pair_conversation_id: String, pair_conversation_id: String,
project_id: domain::ProjectId,
root: &ProjectPath, root: &ProjectPath,
node_id: Option<NodeId>, node_id: Option<NodeId>,
size: PtySize, size: PtySize,
@ -1889,7 +1916,7 @@ impl LaunchAgent {
// Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en // Enregistre la session vivante (invariant « 1 session/agent » : déjà gardé en
// amont sur les deux registres). // 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) ── // ── SÉPARATION DES DEUX CLÉS (ARCHITECTURE §19.7, lot P8a) ──
// - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur // - **id de paire** (`pair_conversation_id`) : clé **logique** persistée sur

View File

@ -23,7 +23,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; 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::ports::{Clock, EventBus, ScheduledTask, Scheduler};
use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit}; use domain::session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
use domain::DomainEvent; use domain::DomainEvent;
@ -57,6 +57,7 @@ pub trait AgentResumer: Send + Sync {
/// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`. /// démarrage de session…). Le service propage l'erreur sans publier `AgentResumed`.
async fn resume( async fn resume(
&self, &self,
project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
conversation_id: Option<String>, conversation_id: Option<String>,
@ -71,7 +72,7 @@ pub struct SessionLimitService {
events: Arc<dyn EventBus>, events: Arc<dyn EventBus>,
resumer: Arc<dyn AgentResumer>, resumer: Arc<dyn AgentResumer>,
/// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire). /// Reprises **armées** non encore tirées : `agent_id → ScheduleId` (en mémoire).
armed: Mutex<HashMap<AgentId, ScheduleId>>, armed: Mutex<HashMap<RuntimeAgentKey, ScheduleId>>,
} }
impl SessionLimitService { impl SessionLimitService {
@ -106,6 +107,7 @@ impl SessionLimitService {
/// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement). /// la confirmation UI est LS6/LS8 — ici on émet seulement l'événement).
pub fn on_rate_limited( pub fn on_rate_limited(
&self, &self,
project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
conversation_id: Option<String>, conversation_id: Option<String>,
@ -119,7 +121,14 @@ impl SessionLimitService {
fire_at_ms, fire_at_ms,
conversation_id, 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 => { ResumePlan::HumanFallback => {
self.events.publish(DomainEvent::AgentRateLimited { self.events.publish(DomainEvent::AgentRateLimited {
@ -147,6 +156,7 @@ impl SessionLimitService {
/// `Some`) ; on le traite en no-op défensif pour rester total. /// `Some`) ; on le traite en no-op défensif pour rester total.
pub fn confirm_human_resume( pub fn confirm_human_resume(
&self, &self,
project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
conversation_id: Option<String>, conversation_id: Option<String>,
@ -161,6 +171,7 @@ impl SessionLimitService {
} = plan_resume(now, &limit, conversation_id) } = plan_resume(now, &limit, conversation_id)
{ {
self.arm_scheduled( self.arm_scheduled(
project_id,
agent_id, agent_id,
fire_at_ms, fire_at_ms,
node_id, node_id,
@ -183,6 +194,7 @@ impl SessionLimitService {
/// publiant l'heure de reset brute, pas l'échéance clampée. /// publiant l'heure de reset brute, pas l'échéance clampée.
fn arm_scheduled( fn arm_scheduled(
&self, &self,
project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
fire_at_ms: i64, fire_at_ms: i64,
node_id: NodeId, node_id: NodeId,
@ -195,10 +207,12 @@ impl SessionLimitService {
}); });
// Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule // Dédoublonnage (§21.10-4) : un signal de rafraîchissement annule
// l'armement précédent (sans événement d'annulation : c'est interne). // 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( let id = self.scheduler.arm(
fire_at_ms, fire_at_ms,
ScheduledTask::ResumeAgent { ScheduledTask::ResumeAgent {
project_id,
agent_id, agent_id,
node_id, node_id,
conversation_id, conversation_id,
@ -207,7 +221,7 @@ impl SessionLimitService {
self.armed self.armed
.lock() .lock()
.expect("session-limit mutex sain") .expect("session-limit mutex sain")
.insert(agent_id, id); .insert(key, id);
self.events.publish(DomainEvent::AgentResumeScheduled { self.events.publish(DomainEvent::AgentResumeScheduled {
agent_id, agent_id,
fire_at_ms, fire_at_ms,
@ -225,16 +239,23 @@ impl SessionLimitService {
/// ce cas `AgentResumed` n'est **pas** publié. /// ce cas `AgentResumed` n'est **pas** publié.
pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> { pub async fn execute_resume(&self, task: ScheduledTask) -> Result<(), AppError> {
let ScheduledTask::ResumeAgent { let ScheduledTask::ResumeAgent {
project_id,
agent_id, agent_id,
node_id, node_id,
conversation_id, conversation_id,
} = task; } = task;
// Le réveil a tiré : l'entrée armée n'a plus lieu d'être (qu'on réussisse ou non). // 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 self.resumer
.resume(agent_id, node_id, conversation_id, RESUME_PROMPT) .resume(
project_id,
agent_id,
node_id,
conversation_id,
RESUME_PROMPT,
)
.await?; .await?;
self.events.publish(DomainEvent::AgentResumed { agent_id }); 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 /// en cours la retirera) : la reprise **suit son cours**, cohérent et sans
/// événement trompeur. /// événement trompeur.
pub fn cancel_resume(&self, agent_id: AgentId) -> bool { pub fn cancel_resume(&self, agent_id: AgentId) -> bool {
let id = self let ids = {
.armed let armed = self.armed.lock().expect("session-limit mutex sain");
.lock() armed
.expect("session-limit mutex sain") .iter()
.get(&agent_id) .filter_map(|(key, id)| (key.agent_id == agent_id).then_some((*key, *id)))
.copied(); .collect::<Vec<_>>()
let Some(id) = id else {
return false; // aucune reprise armée pour cet agent.
}; };
if ids.is_empty() {
return false; // aucune reprise armée pour cet agent.
}
if self.scheduler.cancel(id) { let mut cancelled_any = false;
self.armed for (key, id) in ids {
.lock() if self.scheduler.cancel(id) {
.expect("session-limit mutex sain") self.armed
.remove(&agent_id); .lock()
.expect("session-limit mutex sain")
.remove(&key);
cancelled_any = true;
}
}
if cancelled_any {
self.events self.events
.publish(DomainEvent::AgentResumeCancelled { agent_id }); .publish(DomainEvent::AgentResumeCancelled { agent_id });
true true
@ -281,12 +309,12 @@ impl SessionLimitService {
/// Retire (best-effort) l'armement de `agent_id` et annule le réveil sous-jacent /// 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 /// s'il existe. Usage interne (rafraîchissement / nettoyage post-tir) — **ne publie
/// aucun événement** (contrairement à [`Self::cancel_resume`]). /// aucun événement** (contrairement à [`Self::cancel_resume`]).
fn disarm(&self, agent_id: AgentId) { fn disarm(&self, key: RuntimeAgentKey) {
let previous = self let previous = self
.armed .armed
.lock() .lock()
.expect("session-limit mutex sain") .expect("session-limit mutex sain")
.remove(&agent_id); .remove(&key);
if let Some(id) = previous { if let Some(id) = previous {
self.scheduler.cancel(id); self.scheduler.cancel(id);
} }

View File

@ -17,7 +17,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use domain::conversation::ConversationParty; use domain::conversation::ConversationParty;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::AgentId; use domain::ids::{AgentId, RuntimeAgentKey};
use domain::input::InputMediator; use domain::input::InputMediator;
use domain::mailbox::TicketId; use domain::mailbox::TicketId;
use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyEvent, ReplyStream}; use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyEvent, ReplyStream};
@ -147,7 +147,7 @@ pub async fn drain_with_readiness(
prompt: &str, prompt: &str,
timeout: Option<Duration>, timeout: Option<Duration>,
mediator: &dyn InputMediator, mediator: &dyn InputMediator,
agent: AgentId, agent: RuntimeAgentKey,
) -> Result<String, AgentSessionError> { ) -> Result<String, AgentSessionError> {
match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? { match drain_with_readiness_outcome(session, prompt, timeout, mediator, agent).await? {
TurnOutcome::Completed(content) => Ok(content), TurnOutcome::Completed(content) => Ok(content),
@ -164,7 +164,7 @@ pub async fn drain_with_readiness_and_announcements(
prompt: &str, prompt: &str,
timeout: Option<Duration>, timeout: Option<Duration>,
mediator: &dyn InputMediator, mediator: &dyn InputMediator,
agent: AgentId, agent: RuntimeAgentKey,
announcements: Option<AnnouncementPublisher>, announcements: Option<AnnouncementPublisher>,
) -> Result<String, AgentSessionError> { ) -> Result<String, AgentSessionError> {
match drain_with_readiness_and_announcements_outcome( match drain_with_readiness_and_announcements_outcome(
@ -191,7 +191,7 @@ pub async fn drain_with_readiness_and_announcements_outcome(
prompt: &str, prompt: &str,
timeout: Option<Duration>, timeout: Option<Duration>,
mediator: &dyn InputMediator, mediator: &dyn InputMediator,
agent: AgentId, agent: RuntimeAgentKey,
announcements: Option<AnnouncementPublisher>, announcements: Option<AnnouncementPublisher>,
) -> Result<TurnOutcome, AgentSessionError> { ) -> Result<TurnOutcome, AgentSessionError> {
let Some(publisher) = announcements else { 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( pub async fn drain_reply_stream_with_readiness(
stream: ReplyStream, stream: ReplyStream,
mediator: &dyn InputMediator, mediator: &dyn InputMediator,
agent: AgentId, agent: RuntimeAgentKey,
) -> Result<String, AgentSessionError> { ) -> Result<String, AgentSessionError> {
match drain_stream_to_final( match drain_stream_to_final(
stream, stream,
@ -283,7 +283,7 @@ pub async fn drain_with_readiness_outcome(
prompt: &str, prompt: &str,
timeout: Option<Duration>, timeout: Option<Duration>,
mediator: &dyn InputMediator, mediator: &dyn InputMediator,
agent: AgentId, agent: RuntimeAgentKey,
) -> Result<TurnOutcome, AgentSessionError> { ) -> Result<TurnOutcome, AgentSessionError> {
// `on_signal` ne reçoit QUE les événements terminaux (le `Final` ⇒ `TurnEnded`) : // `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é // 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)) 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 /// Session factice : `send` rejoue une liste fixe d'événements (terminée par un
/// `Final`). /// `Final`).
struct FakeSession { struct FakeSession {
@ -446,23 +450,23 @@ mod tests {
calls: Mutex<Vec<&'static str>>, calls: Mutex<Vec<&'static str>>,
} }
impl InputMediator for RecordingMediator { 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") unreachable!("non utilisé par drain_with_readiness")
} }
fn preempt(&self, _agent: AgentId) {} fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, _agent: AgentId) { fn mark_idle(&self, _agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push("idle"); self.calls.lock().unwrap().push("idle");
} }
fn mark_alive(&self, _agent: AgentId) { fn mark_alive(&self, _agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push("alive"); self.calls.lock().unwrap().push("alive");
} }
fn busy_state(&self, _agent: AgentId) -> AgentBusyState { fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState {
AgentBusyState::Idle AgentBusyState::Idle
} }
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {} fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {}
fn bind_handle_with_submit( fn bind_handle_with_submit(
&self, &self,
_agent: AgentId, _agent: RuntimeAgentKey,
_handle: PtyHandle, _handle: PtyHandle,
_submit: SubmitConfig, _submit: SubmitConfig,
) { ) {
@ -498,7 +502,7 @@ mod tests {
], ],
}; };
let mediator = RecordingMediator::default(); 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 .await
.expect("drain ok"); .expect("drain ok");
assert_eq!(out, "fini"); assert_eq!(out, "fini");
@ -534,7 +538,7 @@ mod tests {
"go", "go",
None, None,
&mediator, &mediator,
target, RuntimeAgentKey::new(project_id, target),
Some(AnnouncementPublisher { Some(AnnouncementPublisher {
bus: bus.clone(), bus: bus.clone(),
project_id, project_id,

View File

@ -58,8 +58,8 @@ pub use agent::{
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput, SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput, SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome, SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
}; };
pub use background::{ pub use background::{
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput, BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,

View File

@ -29,7 +29,7 @@ use domain::project::ProjectPath;
use domain::{ use domain::{
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult,
BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand, BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand,
OrchestratorVisibility, ProfileId, Project, TaskId, OrchestratorVisibility, ProfileId, Project, RuntimeAgentKey, TaskId,
}; };
use crate::conversation::RecordTurn; use crate::conversation::RecordTurn;
@ -59,6 +59,10 @@ const DEFAULT_ROWS: u16 = 24;
/// See [`DEFAULT_ROWS`]. /// See [`DEFAULT_ROWS`].
const DEFAULT_COLS: u16 = 80; 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 /// Submit defaults for delegated prompts, after applying profile-specific
/// compatibility fallbacks for existing saved profiles. /// compatibility fallbacks for existing saved profiles.
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig { fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
@ -234,7 +238,7 @@ fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
struct BusyTurnGuard { struct BusyTurnGuard {
input: Arc<dyn InputMediator>, input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>, mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: AgentId, agent: RuntimeAgentKey,
ticket: TicketId, ticket: TicketId,
armed: bool, armed: bool,
} }
@ -244,7 +248,7 @@ impl BusyTurnGuard {
fn new( fn new(
input: Arc<dyn InputMediator>, input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>, mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: AgentId, agent: RuntimeAgentKey,
ticket: TicketId, ticket: TicketId,
) -> Self { ) -> Self {
Self { Self {
@ -279,7 +283,7 @@ impl Drop for BusyTurnGuard {
// blocage à diagnostiquer. // blocage à diagnostiquer.
crate::diag!( crate::diag!(
"[rendezvous] busy-guard freed target agent {} (ticket {})", "[rendezvous] busy-guard freed target agent {} (ticket {})",
self.agent, self.agent.agent_id,
self.ticket, self.ticket,
); );
} }
@ -1116,7 +1120,7 @@ impl OrchestratorService {
from, from,
ticket, ticket,
result, result,
} => self.reply(from, ticket, result), } => self.reply(project, from, ticket, result),
OrchestratorCommand::ListAgents => self.list_agents(project).await, OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await, OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => { 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 { match visibility {
OrchestratorVisibility::Background => { OrchestratorVisibility::Background => {
return Ok(OrchestratorOutcome { return Ok(OrchestratorOutcome {
@ -1516,12 +1523,14 @@ impl OrchestratorService {
// hôte est légitime (rebind de vue), mais viser un **autre** node // hôte est légitime (rebind de vue), mais viser un **autre** node
// pour un agent singleton déjà vivant est un second lancement ⇒ // pour un agent singleton déjà vivant est un second lancement ⇒
// refus `AgentAlreadyRunning`. // 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) { match ReattachDecision::resolve(Some(node_id), host_node, None) {
ReattachDecision::Rebind { node_id } => { ReattachDecision::Rebind { node_id } => {
let session = self let session = self
.sessions .sessions
.rebind_agent_node(&agent_id, node_id) .rebind_agent_node_in_project(project.id, &agent_id, node_id)
.ok_or_else(|| { .ok_or_else(|| {
AppError::NotFound(format!( AppError::NotFound(format!(
"running session {session_id} for agent {name}" "running session {session_id} for agent {name}"
@ -1620,7 +1629,6 @@ impl OrchestratorService {
.await? .await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?; .ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
let agent_id = agent.id; let agent_id = agent.id;
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la // 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 // 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. // 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 // 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 // User↔B. La session vivante est désormais keyée par conversation (lève
// `session-registry-agent-ambiguity`). // `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 // 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. // 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. // Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
let prompt_source = match requester { 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 // 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é). // 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 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 let rendezvous_task = self
.start_rendezvous_task( .start_rendezvous_task(
project, project,
@ -1809,7 +1818,7 @@ impl OrchestratorService {
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est // (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
// le fix de la cause racine du blocage `Busy` à vie. // le fix de la cause racine du blocage `Busy` à vie.
let busy_guard = 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 // 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` // PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final`
@ -1840,7 +1849,7 @@ impl OrchestratorService {
&task, &task,
None, None,
input.as_ref(), input.as_ref(),
agent_id, agent_key,
announcement_publisher, announcement_publisher,
); );
@ -1865,8 +1874,11 @@ impl OrchestratorService {
if let (Some(service), Some(structured)) = if let (Some(service), Some(structured)) =
(&self.session_limits, &self.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( service.on_rate_limited(
project.id,
agent_id, agent_id,
node_id, node_id,
conversation_id, conversation_id,
@ -1982,8 +1994,8 @@ impl OrchestratorService {
// Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de // 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. // comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII.
mailbox.cancel_head(agent_id, ticket_id); mailbox.cancel_head(agent_key, ticket_id);
input.mark_idle(agent_id); input.mark_idle(agent_key);
busy_guard.disarm(); busy_guard.disarm();
// Checkpoint Response (best-effort), AVANT de déplacer `result`. // Checkpoint Response (best-effort), AVANT de déplacer `result`.
@ -2046,7 +2058,8 @@ impl OrchestratorService {
let target = target.as_str(); let target = target.as_str();
// User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask. // 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 // Ensure the target is live for this thread and bind its input handle on the
// mediator (delivery path). Same call the ask path uses. // mediator (delivery path). Same call the ask path uses.
@ -2061,15 +2074,15 @@ impl OrchestratorService {
// de gate (livraison immédiate, sinon blocage indéfini). // de gate (livraison immédiate, sinon blocage indéfini).
let gate_cold_start = cold_launch && has_mcp; let gate_cold_start = cold_launch && has_mcp;
if gate_cold_start { 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- // Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The // forget: we drop the PendingReply (the human reads the terminal). The
// mediator emits AgentBusyChanged at the source on a starting turn. // mediator emits AgentBusyChanged at the source on a starting turn.
let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text); 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 { Ok(OrchestratorOutcome {
detail: format!("submitted human input to agent {target}"), detail: format!("submitted human input to agent {target}"),
@ -2110,7 +2123,7 @@ impl OrchestratorService {
return Err(AppError::NotFound(format!("agent {agent_id}"))); return Err(AppError::NotFound(format!("agent {agent_id}")));
} }
input.preempt(agent_id); input.preempt(runtime_key(project, agent_id));
Ok(OrchestratorOutcome { Ok(OrchestratorOutcome {
detail: format!("interrupted agent {agent_id}"), 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 /// 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 /// 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é. /// 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 { 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 /// 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 /// 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. /// 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}"); crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}");
if let Some(input) = &self.input { if let Some(input) = &self.input {
input.set_front_attached(agent, attached); input.set_front_attached(runtime_key(project, agent), attached);
} else { } else {
crate::diag!( crate::diag!(
"[delivery] front attachment ignored because input mediator is not wired: \ "[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). /// stable per-agent id derived from the target (legacy routing — never panics).
fn resolve_conversation( fn resolve_conversation(
&self, &self,
project: &Project,
requester: Option<AgentId>, requester: Option<AgentId>,
target: AgentId, target: AgentId,
) -> domain::conversation::ConversationId { ) -> domain::conversation::ConversationId {
@ -2200,11 +2219,11 @@ impl OrchestratorService {
}; };
let right = ConversationParty::agent(target); let right = ConversationParty::agent(target);
match &self.conversations { 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, // 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 // 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). // 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. /// matching ask) — typed, never a panic.
fn reply( fn reply(
&self, &self,
project: &Project,
from: AgentId, from: AgentId,
ticket: Option<TicketId>, ticket: Option<TicketId>,
result: String, result: String,
@ -2243,11 +2263,12 @@ impl OrchestratorService {
"idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(), "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) ; // 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). // sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
let correlation = match ticket { let correlation = match ticket {
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result), Some(ticket_id) => mailbox.resolve_ticket(from_key, ticket_id, result),
None => mailbox.resolve(from, result), None => mailbox.resolve(from_key, result),
}; };
// Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a // 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 // 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 // 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. // (and no spurious event) when the mediator is absent or `from` was already idle.
if let Some(input) = self.input.as_ref() { if let Some(input) = self.input.as_ref() {
input.mark_idle(from); input.mark_idle(from_key);
} }
Ok(OrchestratorOutcome { Ok(OrchestratorOutcome {
detail: format!("reply from agent {from} delivered"), 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 // «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 // 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). // agent mono-fil dont la session n'a pas encore été liée à sa conversation).
let existing = self let existing = self.sessions.session_for(conversation_id).or_else(|| {
.sessions self.sessions
.session_for(conversation_id) .session_for_agent_in_project(project.id, &agent_id)
.or_else(|| self.sessions.session_for_agent(&agent_id)); });
if let Some(session_id) = existing { if let Some(session_id) = existing {
if let Some(handle) = self.sessions.handle(&session_id) { if let Some(handle) = self.sessions.handle(&session_id) {
// (Re)lier le fil à cette session vivante (idempotent). Réutilisation // (Re)lier le fil à cette session vivante (idempotent). Réutilisation
@ -2334,11 +2355,14 @@ impl OrchestratorService {
}) })
.await?; .await?;
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| { let session_id = self
AppError::Process(format!( .sessions
"agent {target} n'a pas de session terminal vivante après lancement" .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 // 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. // conversations) ⇒ un prochain ask sur le même fil la réutilise.
self.bind_conversation_session(conversation_id, session_id); self.bind_conversation_session(conversation_id, session_id);
@ -2373,7 +2397,7 @@ impl OrchestratorService {
structured: &Arc<StructuredSessions>, structured: &Arc<StructuredSessions>,
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> { ) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
// Cible déjà chaude : route directe (aucun lancement). // 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)); return Ok(Some(session));
} }
@ -2413,7 +2437,7 @@ impl OrchestratorService {
// Le launcher a inséré la session dans le registre partagé : la relire. // Le launcher a inséré la session dans le registre partagé : la relire.
structured structured
.session_for_agent(&agent_id) .session_for_agent_in_project(project.id, &agent_id)
.map(Some) .map(Some)
.ok_or_else(|| { .ok_or_else(|| {
AppError::Process(format!( AppError::Process(format!(
@ -2528,7 +2552,7 @@ impl OrchestratorService {
let session_id = self let session_id = self
.sessions .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}")))?; .ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?;
self.close_terminal self.close_terminal
@ -2719,7 +2743,7 @@ impl OrchestratorService {
async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration { 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; let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
if let Some(input) = &self.input { 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) resolve_turn_timeout(turn_timeout_ms)
} }
@ -2982,18 +3006,18 @@ mod tests {
impl domain::input::InputMediator for SpyMediator { impl domain::input::InputMediator for SpyMediator {
fn enqueue( fn enqueue(
&self, &self,
_agent: AgentId, _agent: RuntimeAgentKey,
_ticket: domain::mailbox::Ticket, _ticket: domain::mailbox::Ticket,
) -> domain::mailbox::PendingReply { ) -> domain::mailbox::PendingReply {
domain::mailbox::PendingReply::new(Box::pin(async { domain::mailbox::PendingReply::new(Box::pin(async {
Err(domain::mailbox::MailboxError::Cancelled) Err(domain::mailbox::MailboxError::Cancelled)
})) }))
} }
fn preempt(&self, _agent: AgentId) {} fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, agent: AgentId) { fn mark_idle(&self, agent: RuntimeAgentKey) {
self.idled.lock().unwrap().push(agent); 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 domain::input::AgentBusyState::Idle
} }
} }
@ -3005,7 +3029,7 @@ mod tests {
impl domain::mailbox::AgentMailbox for SpyMailbox { impl domain::mailbox::AgentMailbox for SpyMailbox {
fn enqueue( fn enqueue(
&self, &self,
_agent: AgentId, _agent: RuntimeAgentKey,
_ticket: domain::mailbox::Ticket, _ticket: domain::mailbox::Ticket,
) -> domain::mailbox::PendingReply { ) -> domain::mailbox::PendingReply {
domain::mailbox::PendingReply::new(Box::pin(async { domain::mailbox::PendingReply::new(Box::pin(async {
@ -3014,13 +3038,16 @@ mod tests {
} }
fn resolve( fn resolve(
&self, &self,
_agent: AgentId, _agent: RuntimeAgentKey,
_result: String, _result: String,
) -> Result<(), domain::mailbox::MailboxError> { ) -> Result<(), domain::mailbox::MailboxError> {
Ok(()) Ok(())
} }
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) { fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
self.cancelled.lock().unwrap().push((agent, ticket_id)); self.cancelled
.lock()
.unwrap()
.push((agent.agent_id, ticket_id));
} }
} }
@ -3030,6 +3057,9 @@ mod tests {
fn tid(n: u128) -> TicketId { fn tid(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n)) 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 /// 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é). /// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
@ -3041,7 +3071,7 @@ mod tests {
let _g = BusyTurnGuard::new( let _g = BusyTurnGuard::new(
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>, Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>, Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
aid(1), rkey(1),
tid(7), tid(7),
); );
} // Drop ici. } // Drop ici.
@ -3066,7 +3096,7 @@ mod tests {
let g = BusyTurnGuard::new( let g = BusyTurnGuard::new(
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>, Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>, Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
aid(1), rkey(1),
tid(7), tid(7),
); );
g.disarm(); g.disarm();

View File

@ -10,7 +10,7 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::background_task::{BackgroundTask, BackgroundTaskResult}; use domain::background_task::{BackgroundTask, BackgroundTaskResult};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::AgentId; use domain::ids::{AgentId, RuntimeAgentKey};
use domain::inbox::{AgentInbox, InboxItem, InboxItemKind, InboxSource}; use domain::inbox::{AgentInbox, InboxItem, InboxItemKind, InboxSource};
use domain::input::InputMediator; use domain::input::InputMediator;
use domain::mailbox::{AgentMailbox, Ticket}; use domain::mailbox::{AgentMailbox, Ticket};
@ -90,25 +90,26 @@ impl AgentWakeService {
agent: AgentId, agent: AgentId,
reason: WakeReason, reason: WakeReason,
) -> Result<(), WakeError> { ) -> Result<(), WakeError> {
let key = RuntimeAgentKey::new(project.id, agent);
self.publish(DomainEvent::AgentWakeScheduled { self.publish(DomainEvent::AgentWakeScheduled {
project_id: project.id, project_id: project.id,
agent_id: agent, 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 }); 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(()); return Ok(());
}; };
let delivery = self.delivery_from_item(item, &reason).await?; let delivery = self.delivery_from_item(item, &reason).await?;
let ticket = Ticket::new(delivery.ticket_id, "IdeA", delivery.prompt.clone()); 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( let guard = WakeTurnGuard::new(
Arc::clone(&self.input), Arc::clone(&self.input),
Arc::clone(&self.mailbox), Arc::clone(&self.mailbox),
agent, key,
delivery.ticket_id, delivery.ticket_id,
); );
@ -133,12 +134,12 @@ impl AgentWakeService {
owner_agent_id: agent, 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 .await
.map_err(|err| WakeError::Session(err.to_string()))?; .map_err(|err| WakeError::Session(err.to_string()))?;
self.mailbox.cancel_head(agent, delivery.ticket_id); self.mailbox.cancel_head(key, delivery.ticket_id);
self.input.mark_idle(agent); self.input.mark_idle(key);
guard.disarm(); guard.disarm();
Ok(()) Ok(())
} }
@ -220,7 +221,7 @@ struct WakeDelivery {
struct WakeTurnGuard { struct WakeTurnGuard {
input: Arc<dyn InputMediator>, input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>, mailbox: Arc<dyn AgentMailbox>,
agent: AgentId, agent: RuntimeAgentKey,
ticket: domain::mailbox::TicketId, ticket: domain::mailbox::TicketId,
armed: bool, armed: bool,
} }
@ -229,7 +230,7 @@ impl WakeTurnGuard {
fn new( fn new(
input: Arc<dyn InputMediator>, input: Arc<dyn InputMediator>,
mailbox: Arc<dyn AgentMailbox>, mailbox: Arc<dyn AgentMailbox>,
agent: AgentId, agent: RuntimeAgentKey,
ticket: domain::mailbox::TicketId, ticket: domain::mailbox::TicketId,
) -> Self { ) -> Self {
Self { Self {

View File

@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId; use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle}; 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. /// Runtime family of a live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -25,6 +25,8 @@ pub enum LiveSessionKind {
/// Read-only coordinates of one live agent session. /// Read-only coordinates of one live agent session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LiveSessionSnapshot { pub struct LiveSessionSnapshot {
/// The project owning the live session.
pub project_id: ProjectId,
/// The agent owning the live session. /// The agent owning the live session.
pub agent_id: AgentId, pub agent_id: AgentId,
/// The layout node currently hosting the session view. /// 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. /// A registered, live terminal: its PTY handle plus the domain snapshot.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct Entry { struct Entry {
project_id: ProjectId,
handle: PtyHandle, handle: PtyHandle,
session: TerminalSession, session: TerminalSession,
} }
@ -51,7 +54,7 @@ struct Entry {
/// implementation. /// implementation.
pub trait LiveAgentRegistry: Send + Sync { pub trait LiveAgentRegistry: Send + Sync {
/// Whether `agent_id` currently has a live session in the registry. /// 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. /// Whether `node_id` (a layout leaf) currently hosts a live session.
/// ///
@ -75,8 +78,9 @@ pub struct TerminalSessions {
} }
impl LiveAgentRegistry for TerminalSessions { impl LiveAgentRegistry for TerminalSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool { fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some() self.session_for_agent_in_project(project_id, agent_id)
.is_some()
} }
fn is_node_live(&self, node_id: &NodeId) -> bool { 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 /// may take part in several threads, so this is the **plural** of
/// [`Self::session_for_agent`]). /// [`Self::session_for_agent`]).
#[must_use] #[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> { pub fn sessions_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Vec<SessionId> {
self.entries self.entries
.lock() .lock()
.map(|m| { .map(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id) .map(|e| e.session.id)
.collect() .collect()
@ -142,13 +151,38 @@ impl TerminalSessions {
.unwrap_or_default() .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<SessionId> {
self.sessions_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
}
/// Inserts a freshly-opened session. /// 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() { 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. /// Returns the [`PtyHandle`] for a session, if registered.
#[must_use] #[must_use]
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> { pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
@ -179,14 +213,26 @@ impl TerminalSessions {
/// one live session per agent, so the first match is *the* match. `find` /// one live session per agent, so the first match is *the* match. `find`
/// short-circuits on it. /// short-circuits on it.
#[must_use] #[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> { pub fn session_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.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<SessionId> {
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. /// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
/// ///
/// Companion to [`Self::session_for_agent`]: the launch guard needs the /// 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 /// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same
/// one-live-session-per-agent invariant. /// one-live-session-per-agent invariant.
#[must_use] #[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> { pub fn node_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id)) .find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.node_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<NodeId> {
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. /// Lists every currently-live agent, its current host cell and session id.
/// ///
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`]. /// 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 /// 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). /// is already running elsewhere (it cannot be launched in a second cell).
#[must_use] #[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 self.entries
.lock() .lock()
.map(|m| { .map(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.filter_map(|e| match e.session.kind { .filter_map(|e| match e.session.kind {
SessionKind::Agent { agent_id } => { SessionKind::Agent { agent_id } => {
Some((agent_id, e.session.node_id, e.session.id)) Some((agent_id, e.session.node_id, e.session.id))
@ -224,6 +286,13 @@ impl TerminalSessions {
.unwrap_or_default() .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 /// Rebinds a live agent session to a new visible layout node without
/// respawning the CLI process. /// respawning the CLI process.
/// ///
@ -232,20 +301,36 @@ impl TerminalSessions {
/// in another cell updates only the view binding (`node_id`). The PTY handle, /// in another cell updates only the view binding (`node_id`). The PTY handle,
/// session id, scrollback and process stay untouched. /// session id, scrollback and process stay untouched.
#[must_use] #[must_use]
pub fn rebind_agent_node( pub fn rebind_agent_node_in_project(
&self, &self,
project_id: ProjectId,
agent_id: &AgentId, agent_id: &AgentId,
node_id: NodeId, node_id: NodeId,
) -> Option<TerminalSession> { ) -> Option<TerminalSession> {
self.entries.lock().ok().and_then(|mut m| { self.entries.lock().ok().and_then(|mut m| {
let entry = m.values_mut().find( 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; entry.session.node_id = node_id;
Some(entry.session.clone()) 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<TerminalSession> {
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. /// Returns the [`PtyHandle`]s of every currently-registered session.
/// ///
/// Used at application shutdown to kill all live PTYs cleanly (the /// 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 /// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
/// [`TerminalSessions`]. /// [`TerminalSessions`].
struct StructuredEntry { struct StructuredEntry {
/// Projet propriétaire de la session runtime.
project_id: ProjectId,
/// La session vivante (ressource process/SDK), derrière le port domaine. /// La session vivante (ressource process/SDK), derrière le port domaine.
session: Arc<dyn AgentSession>, session: Arc<dyn AgentSession>,
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »). /// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
@ -328,8 +415,9 @@ pub struct StructuredSessions {
} }
impl LiveAgentRegistry for StructuredSessions { impl LiveAgentRegistry for StructuredSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool { fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.session_for_agent(agent_id).is_some() self.session_for_agent_in_project(project_id, agent_id)
.is_some()
} }
fn is_node_live(&self, node_id: &NodeId) -> bool { 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 /// 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`]). /// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) { pub fn insert_in_project(
&self,
project_id: ProjectId,
session: Arc<dyn AgentSession>,
agent_id: AgentId,
node_id: NodeId,
) {
if let Ok(mut map) = self.entries.lock() { if let Ok(mut map) = self.entries.lock() {
let id = session.id(); let id = session.id();
map.insert( map.insert(
id, id,
StructuredEntry { StructuredEntry {
project_id,
session, session,
agent_id, agent_id,
node_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<dyn AgentSession>, 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. /// Retourne la session enregistrée pour un id, si présente.
#[must_use] #[must_use]
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> { pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
@ -434,36 +540,72 @@ impl StructuredSessions {
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par /// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match. /// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
#[must_use] #[must_use]
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> { pub fn session_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id) .find(|e| &e.agent_id == agent_id)
.map(|e| Arc::clone(&e.session)) .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<Arc<dyn AgentSession>> {
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. /// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
#[must_use] #[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> { pub fn session_id_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id) .find(|e| &e.agent_id == agent_id)
.map(|e| e.session.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<SessionId> {
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. /// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
/// ///
/// Jumeau de [`TerminalSessions::node_for_agent`]. /// Jumeau de [`TerminalSessions::node_for_agent`].
#[must_use] #[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> { pub fn node_for_agent_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<NodeId> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.find(|e| &e.agent_id == agent_id) .find(|e| &e.agent_id == agent_id)
.map(|e| e.node_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<NodeId> {
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 /// 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). /// [`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 /// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante. /// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
#[must_use] #[must_use]
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId, Option<String>)> { pub fn meta_for_session(
&self,
id: &SessionId,
) -> Option<(ProjectId, AgentId, NodeId, Option<String>)> {
self.entries.lock().ok().and_then(|m| { self.entries.lock().ok().and_then(|m| {
m.get(id) m.get(id).map(|e| {
.map(|e| (e.agent_id, e.node_id, e.session.conversation_id())) (
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 /// Jumeau de [`TerminalSessions::live_agents`] : un tuple
/// `(AgentId, NodeId, SessionId)` par session structurée vivante. /// `(AgentId, NodeId, SessionId)` par session structurée vivante.
#[must_use] #[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 self.entries
.lock() .lock()
.map(|m| { .map(|m| {
m.values() m.values()
.filter(|e| e.project_id == project_id)
.map(|e| (e.agent_id, e.node_id, e.session.id())) .map(|e| (e.agent_id, e.node_id, e.session.id()))
.collect() .collect()
}) })
.unwrap_or_default() .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 /// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
/// redémarrer la conversation (« la cellule est une vue », §17.6). /// 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 /// 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. /// session rebindée, ou `None` si l'agent n'a pas de session vivante.
#[must_use] #[must_use]
pub fn rebind_agent_node_in_project(
&self,
project_id: ProjectId,
agent_id: &AgentId,
node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> {
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( pub fn rebind_agent_node(
&self, &self,
agent_id: &AgentId, agent_id: &AgentId,
node_id: NodeId, node_id: NodeId,
) -> Option<Arc<dyn AgentSession>> { ) -> Option<Arc<dyn AgentSession>> {
self.entries.lock().ok().and_then(|mut m| { self.rebind_agent_node_in_project(
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?; ProjectId::from_uuid(uuid::Uuid::nil()),
entry.node_id = node_id; agent_id,
Some(Arc::clone(&entry.session)) node_id,
}) )
} }
/// Retire une session du registre, retournant la session si présente (pour que /// 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. /// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
#[must_use] #[must_use]
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> { pub fn session_id_for_agent(
&self,
project_id: ProjectId,
agent_id: &AgentId,
) -> Option<SessionId> {
self.pty self.pty
.session_for_agent(agent_id) .session_for_agent_in_project(project_id, agent_id)
.or_else(|| self.structured.session_id_for_agent(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. /// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
#[must_use] #[must_use]
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> { pub fn node_for_agent(&self, project_id: ProjectId, agent_id: &AgentId) -> Option<NodeId> {
self.pty self.pty
.node_for_agent(agent_id) .node_for_agent_in_project(project_id, agent_id)
.or_else(|| self.structured.node_for_agent(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). /// Tous les agents vivants des deux registres (PTY puis structurés).
#[must_use] #[must_use]
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> { pub fn live_agents(&self, project_id: ProjectId) -> Vec<(AgentId, NodeId, SessionId)> {
let mut all = self.pty.live_agents(); let mut all = self.pty.live_agents_in_project(project_id);
all.extend(self.structured.live_agents()); all.extend(self.structured.live_agents_in_project(project_id));
all all
} }
@ -632,30 +821,48 @@ impl LiveSessions {
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> { pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
let mut all: Vec<LiveSessionSnapshot> = self let mut all: Vec<LiveSessionSnapshot> = self
.pty .pty
.live_agents() .entries
.into_iter() .lock()
.map(|(agent_id, node_id, session_id)| LiveSessionSnapshot { .map(|m| {
agent_id, m.values()
node_id, .filter_map(|e| match e.session.kind {
session_id, SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
kind: LiveSessionKind::Pty, 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(); .unwrap_or_default();
all.extend(self.structured.live_agents().into_iter().map( all.extend(
|(agent_id, node_id, session_id)| LiveSessionSnapshot { self.structured
agent_id, .entries
node_id, .lock()
session_id, .map(|m| {
kind: LiveSessionKind::Structured, 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::<Vec<_>>()
})
.unwrap_or_default(),
);
all all
} }
} }
impl LiveAgentRegistry for LiveSessions { impl LiveAgentRegistry for LiveSessions {
fn is_agent_live(&self, agent_id: &AgentId) -> bool { fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id) 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 { fn is_node_live(&self, node_id: &NodeId) -> bool {

View File

@ -10,7 +10,7 @@
use std::sync::Arc; use std::sync::Arc;
use domain::input::InputMediator; use domain::input::InputMediator;
use domain::{AgentId, NodeId, Project, SessionId}; use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
use crate::error::AppError; use crate::error::AppError;
use crate::orchestrator::OrchestratorService; use crate::orchestrator::OrchestratorService;
@ -65,11 +65,11 @@ impl AttachLiveAgent {
/// [`AppError::NotFound`] when the agent has no live session in either registry. /// [`AppError::NotFound`] when the agent has no live session in either registry.
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> { pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match). // PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
if let Some(session) = self if let Some(session) = self.live.pty.rebind_agent_node_in_project(
.live input.project.id,
.pty &input.agent_id,
.rebind_agent_node(&input.agent_id, input.node_id) input.node_id,
{ ) {
return Ok(AttachLiveAgentOutput { return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id, agent_id: input.agent_id,
node_id: session.node_id, node_id: session.node_id,
@ -77,11 +77,11 @@ impl AttachLiveAgent {
kind: LiveSessionKind::Pty, kind: LiveSessionKind::Pty,
}); });
} }
if let Some(session) = self if let Some(session) = self.live.structured.rebind_agent_node_in_project(
.live input.project.id,
.structured &input.agent_id,
.rebind_agent_node(&input.agent_id, input.node_id) input.node_id,
{ ) {
return Ok(AttachLiveAgentOutput { return Ok(AttachLiveAgentOutput {
agent_id: input.agent_id, agent_id: input.agent_id,
node_id: input.node_id, node_id: input.node_id,
@ -168,28 +168,37 @@ impl StopLiveAgent {
&self, &self,
input: StopLiveAgentInput, input: StopLiveAgentInput,
) -> Result<StopLiveAgentOutput, AppError> { ) -> Result<StopLiveAgentOutput, AppError> {
self.stop_dependencies(input.agent_id).await; self.stop_dependencies(&input.project, input.agent_id).await;
if let Some(mediator) = &self.input { 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 { let Some(waits) = &self.waits else {
return; return;
}; };
for dep in waits.active_wait_dependencies(agent_id) { for dep in waits.active_wait_dependencies(agent_id) {
if let Some(mediator) = &self.input { 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<StopLiveAgentOutput, AppError> { async fn stop_one_in_project(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<StopLiveAgentOutput, AppError> {
// PTY first: delegate to the existing close primitive (removes + kills). // 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 self.close
.execute(CloseTerminalInput { session_id }) .execute(CloseTerminalInput { session_id })
.await?; .await?;
@ -201,7 +210,11 @@ impl StopLiveAgent {
} }
// Structured: remove from the registry first (so the uniqueness guard no // 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. // 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) { if let Some(session) = self.live.structured.remove(&session_id) {
session session
.shutdown() .shutdown()

View File

@ -461,7 +461,12 @@ impl GetProjectWorkState {
input: GetProjectWorkStateInput, input: GetProjectWorkStateInput,
) -> Result<ProjectWorkState, AppError> { ) -> Result<ProjectWorkState, AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?; 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 { let undelivered_completions = match &self.background_tasks {
Some(store) => Some(store.list_undelivered_completions().await), Some(store) => Some(store.list_undelivered_completions().await),
None => None, None => None,
@ -482,11 +487,12 @@ impl GetProjectWorkState {
// Tickets are crossed with the busy state: only an agent absent from // 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 is dropped (this loop only visits manifest entries), so
// the manifest boundary is naturally preserved. // 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 busy_ticket = busy.ticket();
let tickets = self let tickets = self
.queue .queue
.queue_for(agent.id) .queue_for(runtime_key)
.into_iter() .into_iter()
.map(|snapshot| ticket_state(snapshot, busy_ticket)) .map(|snapshot| ticket_state(snapshot, busy_ticket))
.collect(); .collect();
@ -810,7 +816,9 @@ fn preview(text: &str, max_chars: usize) -> String {
} }
} }
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> { fn live_by_agent(
snapshots: impl IntoIterator<Item = LiveSessionSnapshot>,
) -> HashMap<AgentId, LiveSessionSnapshot> {
let mut out = HashMap::new(); let mut out = HashMap::new();
for snapshot in snapshots { for snapshot in snapshots {
out.entry(snapshot.agent_id).or_insert(snapshot); out.entry(snapshot.agent_id).or_insert(snapshot);

View File

@ -77,10 +77,11 @@ impl ReconcileLiveState {
/// ///
/// # Errors /// # Errors
/// [`AppError::Store`] sur défaillance de chargement ou d'upsert du store. /// [`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 state = self.store.load().await?;
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0); 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 { for entry in reconciled {
self.store.upsert(entry).await?; self.store.upsert(entry).await?;
} }
@ -125,7 +126,7 @@ mod tests {
live: HashSet<AgentId>, live: HashSet<AgentId>,
} }
impl LiveAgentRegistry for FakeRegistry { 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) self.live.contains(agent_id)
} }
fn is_node_live(&self, _node_id: &NodeId) -> bool { fn is_node_live(&self, _node_id: &NodeId) -> bool {

View File

@ -1174,7 +1174,7 @@ fn seed_live_agent_session(
size, size,
); );
session.status = domain::SessionStatus::Running; 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 { 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. // No silent move, no respawn, registry untouched.
assert_eq!( assert_eq!(
sessions.node_for_agent(&agent.id), sessions.node_for_agent_in_project(project().id, &agent.id),
Some(host), Some(host),
"session stays pinned on its host node" "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.id, sid(42), "returns the existing session");
assert_eq!(out.session.node_id, target, "view rebound to target cell"); 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_eq!(sessions.len(), before, "registry size is unchanged");
assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach"); 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). // Live, then removed (close/exit).
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42)); seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
sessions.remove(&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); let mut input = launch_input(agent.id);
input.node_id = Some(nid(2)); input.node_id = Some(nid(2));

View File

@ -7,7 +7,7 @@ use domain::background_task::{
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState,
BackgroundTaskWakePolicy, BackgroundTaskWakePolicy,
}; };
use domain::ids::{AgentId, ProjectId, SessionId, TaskId}; use domain::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId, TaskId};
use domain::inbox::{ use domain::inbox::{
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt, AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt,
InboxReceiptStatus, InboxSource, InboxReceiptStatus, InboxSource,
@ -30,6 +30,10 @@ fn agent(n: u128) -> AgentId {
AgentId::from_uuid(id(n)) 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 { fn task_id(n: u128) -> TaskId {
TaskId::from_uuid(id(n)) TaskId::from_uuid(id(n))
} }
@ -93,44 +97,46 @@ fn completed_task(
#[derive(Default)] #[derive(Default)]
struct FakeInbox { struct FakeInbox {
queues: Mutex<HashMap<AgentId, VecDeque<InboxItem>>>, queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<InboxItem>>>,
} }
impl AgentInbox for FakeInbox { impl AgentInbox for FakeInbox {
fn enqueue_message( fn enqueue_message(
&self, &self,
agent_id: AgentId, agent: RuntimeAgentKey,
item: InboxItem, item: InboxItem,
) -> Result<InboxReceipt, InboxError> { ) -> Result<InboxReceipt, InboxError> {
let mut queues = self.queues.lock().unwrap(); 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; let item_id = item.id;
queue.push_back(item); queue.push_back(item);
Ok(InboxReceipt { Ok(InboxReceipt {
item_id, item_id,
agent_id, agent_id: agent.agent_id,
runtime_key: agent,
depth: queue.len(), depth: queue.len(),
status: InboxReceiptStatus::Queued, status: InboxReceiptStatus::Queued,
}) })
} }
fn dequeue_next(&self, agent_id: AgentId) -> Option<InboxItem> { fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem> {
self.queues self.queues
.lock() .lock()
.unwrap() .unwrap()
.entry(agent_id) .entry(agent)
.or_default() .or_default()
.pop_front() .pop_front()
} }
fn snapshot(&self, agent_id: AgentId) -> AgentInboxSnapshot { fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot {
let queues = self.queues.lock().unwrap(); let queues = self.queues.lock().unwrap();
let items = queues let items = queues
.get(&agent_id) .get(&agent)
.map(|queue| queue.iter().cloned().collect::<Vec<_>>()) .map(|queue| queue.iter().cloned().collect::<Vec<_>>())
.unwrap_or_default(); .unwrap_or_default();
AgentInboxSnapshot { AgentInboxSnapshot {
agent_id, agent_id: agent.agent_id,
runtime_key: agent,
depth: items.len(), depth: items.len(),
items, items,
} }
@ -139,14 +145,14 @@ impl AgentInbox for FakeInbox {
#[derive(Default)] #[derive(Default)]
struct SharedTurnState { struct SharedTurnState {
busy: Mutex<HashMap<AgentId, AgentBusyState>>, busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
tickets: Mutex<HashMap<AgentId, VecDeque<Ticket>>>, tickets: Mutex<HashMap<RuntimeAgentKey, VecDeque<Ticket>>>,
} }
impl SharedTurnState { impl SharedTurnState {
fn force_busy(&self, agent_id: AgentId, ticket_id: TicketId) { fn force_busy(&self, agent_id: AgentId, ticket_id: TicketId) {
self.busy.lock().unwrap().insert( self.busy.lock().unwrap().insert(
agent_id, runtime_key(agent_id),
AgentBusyState::Busy { AgentBusyState::Busy {
ticket: ticket_id, ticket: ticket_id,
since_ms: 1, since_ms: 1,
@ -158,20 +164,20 @@ impl SharedTurnState {
self.tickets self.tickets
.lock() .lock()
.unwrap() .unwrap()
.get(&agent_id) .get(&runtime_key(agent_id))
.map(VecDeque::len) .map(VecDeque::len)
.unwrap_or_default() .unwrap_or_default()
} }
} }
impl InputMediator for SharedTurnState { impl InputMediator for SharedTurnState {
fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply { fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
self.enqueue_silent(agent_id, ticket) 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( self.busy.lock().unwrap().insert(
agent_id, agent,
AgentBusyState::Busy { AgentBusyState::Busy {
ticket: ticket.id, ticket: ticket.id,
since_ms: 1, since_ms: 1,
@ -180,43 +186,43 @@ impl InputMediator for SharedTurnState {
self.tickets self.tickets
.lock() .lock()
.unwrap() .unwrap()
.entry(agent_id) .entry(agent)
.or_default() .or_default()
.push_back(ticket); .push_back(ticket);
PendingReply::new(Box::pin(async { Err(MailboxError::Cancelled) })) 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 self.busy
.lock() .lock()
.unwrap() .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 self.busy
.lock() .lock()
.unwrap() .unwrap()
.get(&agent_id) .get(&agent)
.copied() .copied()
.unwrap_or(AgentBusyState::Idle) .unwrap_or(AgentBusyState::Idle)
} }
} }
impl AgentMailbox for SharedTurnState { impl AgentMailbox for SharedTurnState {
fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply { fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
<Self as InputMediator>::enqueue(self, agent_id, ticket) <Self as InputMediator>::enqueue(self, agent, ticket)
} }
fn resolve(&self, _agent: AgentId, _result: String) -> Result<(), MailboxError> { fn resolve(&self, _agent: RuntimeAgentKey, _result: String) -> Result<(), MailboxError> {
Ok(()) 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(); 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) { if queue.front().is_some_and(|ticket| ticket.id == ticket_id) {
queue.pop_front(); 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())); let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
tasks.insert(completed_task(&project, owner, task_id, "build finished")); tasks.insert(completed_task(&project, owner, task_id, "build finished"));
inbox inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, task_id, ticket(20)),
)
.unwrap(); .unwrap();
service(inbox, turns, tasks, sessions) 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())); let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
tasks.insert(completed_task(&project, owner, task_id, "done")); tasks.insert(completed_task(&project, owner, task_id, "done"));
inbox inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, task_id, ticket(20)),
)
.unwrap(); .unwrap();
turns.force_busy(owner, ticket(99)); 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_eq!(err, WakeError::AgentBusy { agent_id: owner });
assert!(session.prompts.lock().unwrap().is_empty()); 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] #[tokio::test]
@ -456,7 +468,10 @@ async fn absent_session_is_launched_or_reattached_by_provider() {
let sessions = Arc::new(FakeSessionProvider::default()); let sessions = Arc::new(FakeSessionProvider::default());
tasks.insert(completed_task(&project, owner, task_id, "done")); tasks.insert(completed_task(&project, owner, task_id, "done"));
inbox inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, task_id, ticket(20)),
)
.unwrap(); .unwrap();
service(inbox, turns, tasks, sessions.clone()) 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")); tasks.insert(completed_task(&project, owner, task_id, "done"));
inbox inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, task_id, ticket(20)),
)
.unwrap(); .unwrap();
service(inbox, turns, tasks.clone(), sessions) 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())); let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
tasks.insert(completed_task(&project, owner, task_id, "done")); tasks.insert(completed_task(&project, owner, task_id, "done"));
inbox inbox
.enqueue_message(owner, completion_item(owner, task_id, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, task_id, ticket(20)),
)
.unwrap(); .unwrap();
let err = service(inbox, turns, tasks.clone(), sessions) 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, first, "first"));
tasks.insert(completed_task(&project, owner, second, "second")); tasks.insert(completed_task(&project, owner, second, "second"));
inbox inbox
.enqueue_message(owner, completion_item(owner, first, ticket(20))) .enqueue_message(
runtime_key(owner),
completion_item(owner, first, ticket(20)),
)
.unwrap(); .unwrap();
inbox inbox
.enqueue_message(owner, completion_item(owner, second, ticket(21))) .enqueue_message(
runtime_key(owner),
completion_item(owner, second, ticket(21)),
)
.unwrap(); .unwrap();
service(inbox.clone(), turns.clone(), tasks, sessions) service(inbox.clone(), turns.clone(), tasks, sessions)
@ -559,6 +586,6 @@ async fn wake_drains_exactly_one_item_per_turn() {
.unwrap(); .unwrap();
assert_eq!(session.prompts.lock().unwrap().len(), 1); 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); assert_eq!(turns.ticket_depth(owner), 0);
} }

View File

@ -670,7 +670,7 @@ fn seed_live_agent_session(
size, size,
); );
session.status = domain::SessionStatus::Running; 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 SessionKind::Agent { agent_id } if agent_id == agent.id
)); ));
assert_eq!( assert_eq!(
f.sessions.session_for_agent(&agent.id), f.sessions
.session_for_agent_in_project(project().id, &agent.id),
Some(sid(777)), Some(sid(777)),
"the registry now holds the relaunched session" "the registry now holds the relaunched session"
); );

View File

@ -23,7 +23,7 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use application::drain_with_readiness; use application::drain_with_readiness;
use domain::ids::AgentId; use domain::ids::{AgentId, ProjectId, RuntimeAgentKey};
use domain::input::{AgentBusyState, InputMediator}; use domain::input::{AgentBusyState, InputMediator};
use domain::mailbox::{PendingReply, Ticket}; use domain::mailbox::{PendingReply, Ticket};
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
@ -38,6 +38,10 @@ fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n)) 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 // Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -121,17 +125,20 @@ impl RecordingMediator {
} }
impl InputMediator for 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. // Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
PendingReply::new(Box::pin(std::future::pending())) PendingReply::new(Box::pin(std::future::pending()))
} }
fn preempt(&self, agent: AgentId) { fn preempt(&self, agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push((agent, "preempt")); self.calls.lock().unwrap().push((agent.agent_id, "preempt"));
} }
fn mark_idle(&self, agent: AgentId) { fn mark_idle(&self, agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push((agent, "mark_idle")); 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 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 session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
let mediator = RecordingMediator::new(); 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!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
assert_eq!( assert_eq!(
@ -200,7 +207,7 @@ async fn intermediate_events_do_not_mark_idle_only_final_does() {
])); ]));
let mediator = RecordingMediator::new(); 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!(out, Ok("hello".to_owned()));
assert_eq!( assert_eq!(
@ -227,7 +234,7 @@ async fn heartbeats_alone_never_mark_idle_before_final() {
])); ]));
let mediator = RecordingMediator::new(); 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!(out, Ok("fini".to_owned()));
assert_eq!(mediator.mark_idle_count(agent), 1); 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 session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
let mediator = RecordingMediator::new(); 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!( assert!(
matches!(out, Err(AgentSessionError::Io(_))), matches!(out, Err(AgentSessionError::Io(_))),
"flux épuisé sans Final ⇒ Io, obtenu {out:?}" "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 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!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
assert_eq!(mediator.mark_idle_count(agent), 0); 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 session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
let mediator = RecordingMediator::new(); 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(drained), 1);
assert_eq!( assert_eq!(
mediator.mark_idle_count(other), mediator.mark_idle_count(other),
@ -328,7 +335,7 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
"x", "x",
Some(Duration::from_millis(20)), Some(Duration::from_millis(20)),
&mediator, &mediator,
agent, key(agent),
) )
.await; .await;
assert_eq!(out, Err(AgentSessionError::Timeout)); assert_eq!(out, Err(AgentSessionError::Timeout));

View File

@ -18,7 +18,7 @@ use async_trait::async_trait;
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry}; use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::SkillId; 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::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle, AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
@ -647,6 +647,12 @@ fn pid(n: u128) -> ProfileId {
fn aid(n: u128) -> AgentId { fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n)) 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 { fn sid(n: u128) -> SessionId {
SessionId::from_uuid(Uuid::from_u128(n)) SessionId::from_uuid(Uuid::from_u128(n))
} }
@ -656,7 +662,7 @@ fn nid(n: u128) -> NodeId {
fn project() -> Project { fn project() -> Project {
Project::new( Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)), project_id(),
"demo", "demo",
ProjectPath::new("/home/me/proj").unwrap(), ProjectPath::new("/home/me/proj").unwrap(),
RemoteRef::local(), RemoteRef::local(),
@ -1201,68 +1207,69 @@ impl CompletionBus {
} }
impl AgentMailbox for TestMailbox { 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::<TurnResolution>(); let (tx, rx) = tokio::sync::oneshot::channel::<TurnResolution>();
self.queues self.queues
.lock() .lock()
.unwrap() .unwrap()
.entry(agent) .entry(agent.agent_id)
.or_default() .or_default()
.push_back((ticket, tx)); .push_back((ticket, tx));
PendingReply::new(Box::pin(async move { PendingReply::new(Box::pin(async move {
rx.await.map_err(|_| MailboxError::Cancelled) 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 slot = {
let mut q = self.queues.lock().unwrap(); let mut q = self.queues.lock().unwrap();
let queue = q let queue = q
.get_mut(&agent) .get_mut(&agent.agent_id)
.filter(|q| !q.is_empty()) .filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?; .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
queue.pop_front().expect("non-empty") queue.pop_front().expect("non-empty")
}; };
self.completions self.completions
.push(agent, TestCompletion::Replied(result.clone())); .push(agent.agent_id, TestCompletion::Replied(result.clone()));
let _ = slot.1.send(TurnResolution::Replied(result)); let _ = slot.1.send(TurnResolution::Replied(result));
Ok(()) Ok(())
} }
fn resolve_ticket( fn resolve_ticket(
&self, &self,
agent: AgentId, agent: RuntimeAgentKey,
ticket_id: TicketId, ticket_id: TicketId,
result: String, result: String,
) -> Result<(), MailboxError> { ) -> Result<(), MailboxError> {
let slot = { let slot = {
let mut q = self.queues.lock().unwrap(); let mut q = self.queues.lock().unwrap();
let queue = q let queue = q
.get_mut(&agent) .get_mut(&agent.agent_id)
.filter(|q| !q.is_empty()) .filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?; .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
let pos = queue let pos = queue
.iter() .iter()
.position(|(t, _)| t.id == ticket_id) .position(|(t, _)| t.id == ticket_id)
.ok_or(MailboxError::NoPendingRequest(agent))?; .ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
queue.remove(pos).expect("found position") queue.remove(pos).expect("found position")
}; };
self.completions self.completions
.push(agent, TestCompletion::Replied(result.clone())); .push(agent.agent_id, TestCompletion::Replied(result.clone()));
let _ = slot.1.send(TurnResolution::Replied(result)); let _ = slot.1.send(TurnResolution::Replied(result));
Ok(()) 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(); 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) { if queue.front().map(|(t, _)| t.id) == Some(ticket_id) {
queue.pop_front(); 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. // Mirror the production adapter: head-only, idempotent, fire-and-forget-safe.
let mut q = self.queues.lock().unwrap(); 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) { if queue.front().map(|(t, _)| t.id) != Some(ticket_id) {
return; return;
} }
@ -1270,7 +1277,8 @@ impl AgentMailbox for TestMailbox {
return; // receiver gone (human submit / timed-out caller): preserve head. return; // receiver gone (human submit / timed-out caller): preserve head.
} }
let (_, tx) = queue.pop_front().expect("head just matched"); 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); let _ = tx.send(TurnResolution::ReturnedToPromptNoReply);
} }
} }
@ -1304,11 +1312,11 @@ impl TestMediator {
} }
} }
impl InputMediator for 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 ticket_id = ticket.id;
{ {
let mut b = self.busy.lock().unwrap(); 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() { if !st.is_busy() {
*st = AgentBusyState::Busy { *st = AgentBusyState::Busy {
ticket: ticket_id, 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!( let line = format!(
"[IdeA · tâche de {} · ticket {}] {}\n", "[IdeA · tâche de {} · ticket {}] {}\n",
ticket.requester, ticket_id, ticket.task ticket.requester, ticket_id, ticket.task
@ -1325,26 +1333,26 @@ impl InputMediator for TestMediator {
} }
self.mailbox.enqueue(agent, ticket) self.mailbox.enqueue(agent, ticket)
} }
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
self.handles.lock().unwrap().insert(agent, handle); self.handles.lock().unwrap().insert(agent.agent_id, handle);
} }
fn delivers_turn(&self, agent: AgentId) -> bool { fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool {
self.handles.lock().unwrap().contains_key(&agent) self.handles.lock().unwrap().contains_key(&agent.agent_id)
} }
fn preempt(&self, agent: AgentId) { fn preempt(&self, agent: RuntimeAgentKey) {
self.preempts.lock().unwrap().push(agent); self.preempts.lock().unwrap().push(agent.agent_id);
} }
fn mark_idle(&self, agent: AgentId) { fn mark_idle(&self, agent: RuntimeAgentKey) {
self.busy self.busy
.lock() .lock()
.unwrap() .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 self.busy
.lock() .lock()
.unwrap() .unwrap()
.get(&agent) .get(&agent.agent_id)
.copied() .copied()
.unwrap_or(AgentBusyState::Idle) .unwrap_or(AgentBusyState::Idle)
} }
@ -1354,29 +1362,38 @@ impl InputMediator for TestMediator {
/// `infrastructure::InMemoryConversationRegistry`. /// `infrastructure::InMemoryConversationRegistry`.
#[derive(Default)] #[derive(Default)]
struct TestConversations { struct TestConversations {
by_pair: Mutex<HashMap<(String, String), ConversationId>>, by_pair: Mutex<HashMap<(ProjectId, String, String), ConversationId>>,
by_id: Mutex<HashMap<ConversationId, Conversation>>, by_id: Mutex<HashMap<ConversationId, Conversation>>,
} }
impl TestConversations { impl TestConversations {
fn new() -> Self { fn new() -> Self {
Self::default() 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 { let s = |p: ConversationParty| match p {
ConversationParty::User => "user".to_owned(), ConversationParty::User => "user".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_string(), ConversationParty::Agent { agent_id } => agent_id.to_string(),
}; };
let (ka, kb) = (s(a), s(b)); let (ka, kb) = (s(a), s(b));
if ka <= kb { if ka <= kb {
(ka, kb) (project_id, ka, kb)
} else { } else {
(kb, ka) (project_id, kb, ka)
} }
} }
} }
impl ConversationRegistry for TestConversations { impl ConversationRegistry for TestConversations {
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation { fn resolve(
let key = Self::key(a, b); &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(); let mut pairs = self.by_pair.lock().unwrap();
if let Some(id) = pairs.get(&key).copied() { if let Some(id) = pairs.get(&key).copied() {
return self.by_id.lock().unwrap().get(&id).cloned().unwrap(); 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 }, SessionKind::Agent { agent_id },
PtySize::new(24, 80).unwrap(), 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. /// 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). // Simulate the grace-window completion (target back at prompt, no idea_reply).
let ticket = fx.mailbox.ticket_ids(&aid(1))[0]; 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) let err = timeout(TEST_GUARD, ask)
.await .await
@ -2135,7 +2152,7 @@ async fn ask_reply_wins_then_late_completion_is_noop() {
.await .await
.expect("reply ok"); .expect("reply ok");
// …then a late grace completion for the same ticket is a no-op. // …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) let out = timeout(TEST_GUARD, ask)
.await .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 // Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
// channel-closed error: no Response turn, hence no harvest. // channel-closed error: no Response turn, hence no harvest.
let ticket = fx.mailbox.ticket_ids(&aid(1))[0]; 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) let out = timeout(TEST_GUARD, ask)
.await .await
@ -2287,7 +2304,7 @@ async fn idea_reply_marks_emitter_idle() {
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// The delegated turn started ⇒ the target is Busy. // The delegated turn started ⇒ the target is Busy.
assert!( assert!(
fx.mediator.busy_state(aid(1)).is_busy(), fx.mediator.busy_state(rkey(1)).is_busy(),
"target Busy while processing the delegated turn" "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. // C5: the reply marked the emitter Idle (FIFO can advance) — no prompt pattern needed.
assert_eq!( assert_eq!(
fx.mediator.busy_state(aid(1)), fx.mediator.busy_state(rkey(1)),
AgentBusyState::Idle, AgentBusyState::Idle,
"idea_reply is the explicit signal that frees the turn" "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. // Le tour a démarré : la cible est Busy, un ticket est en file.
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
assert!( 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é" "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. 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. // 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!( assert_eq!(
fx.mediator.busy_state(aid(1)), fx.mediator.busy_state(rkey(1)),
AgentBusyState::Idle, AgentBusyState::Idle,
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)" "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 }); let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
assert!( 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" "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"); .expect("ask ok");
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour")); assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
assert_eq!( assert_eq!(
fx.mediator.busy_state(aid(1)), fx.mediator.busy_state(rkey(1)),
AgentBusyState::Idle, AgentBusyState::Idle,
"cible Idle après résolution du 2e tour" "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 svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await }); let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).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 // 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. // erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
let t = fx.mailbox.ticket_ids(&aid(1))[0]; 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) let err = timeout(TEST_GUARD, ask)
.await .await
.expect("ask retourne vite sur canal fermé") .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). // Le garde a ramené la cible Idle (la FIFO peut avancer).
assert_eq!( assert_eq!(
fx.mediator.busy_state(aid(1)), fx.mediator.busy_state(rkey(1)),
AgentBusyState::Idle, AgentBusyState::Idle,
"branche erreur ⇒ cible Idle (garde RAII)" "branche erreur ⇒ cible Idle (garde RAII)"
); );
@ -2449,7 +2466,7 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
); );
fx.mailbox fx.mailbox
.resolve(aid(1), "launched reply".to_owned()) .resolve(rkey(1), "launched reply".to_owned())
.expect("structured Final"); .expect("structured Final");
let out = timeout(TEST_GUARD, ask) let out = timeout(TEST_GUARD, ask)
.await .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. // Débloque l'ask via le Final structured.
fx.mailbox fx.mailbox
.resolve(aid(1), "done".to_owned()) .resolve(rkey(1), "done".to_owned())
.expect("structured completion ok"); .expect("structured completion ok");
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
} }
@ -2957,7 +2974,7 @@ async fn f1_ask_without_provider_writes_minimal_mcp_json() {
); );
fx.mailbox fx.mailbox
.resolve(aid(1), "done".to_owned()) .resolve(rkey(1), "done".to_owned())
.expect("structured completion ok"); .expect("structured completion ok");
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); 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; await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.mailbox fx.mailbox
.resolve(aid(1), "codex ok".to_owned()) .resolve(rkey(1), "codex ok".to_owned())
.expect("structured completion ok"); .expect("structured completion ok");
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
assert_eq!(out.reply.as_deref(), Some("codex ok")); 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 }); let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await; await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.mailbox fx.mailbox
.resolve(aid(1), "ok claude".to_owned()) .resolve(rkey(1), "ok claude".to_owned())
.expect("structured completion ok"); .expect("structured completion ok");
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap(); let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
assert_eq!(out.reply.as_deref(), Some("ok claude")); 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. // The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
let a_to_b = fx.conversations.resolve( let a_to_b = fx.conversations.resolve(
project_id(),
ConversationParty::agent(aid(1)), ConversationParty::agent(aid(1)),
ConversationParty::agent(aid(2)), ConversationParty::agent(aid(2)),
); );
let user_b = fx let user_b = fx.conversations.resolve(
.conversations project_id(),
.resolve(ConversationParty::User, ConversationParty::agent(aid(2))); 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_ne!(a_to_b.id, user_b.id, "A↔B is a distinct thread from User↔B");
assert!( assert!(
a_to_b.same_pair( 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 // 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. // 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) let err = timeout(TEST_GUARD, ask)
.await .await
.expect("ask returns promptly once the channel closes") .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" "queue freed after retirement"
); );
assert_eq!( assert_eq!(
fx.sessions.session_for_agent(&aid(2)), fx.sessions
.session_for_agent_in_project(project_id(), &aid(2)),
Some(sid(802)), Some(sid(802)),
"target stays alive for the next turn" "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. // Unblock the delegation so the spawned task ends cleanly.
fx.mailbox 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; 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 }); let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(&json)).await });
await_until(|| fx.mailbox.pending(&reply_from) == 1).await; await_until(|| fx.mailbox.pending(&reply_from) == 1).await;
fx.mailbox fx.mailbox
.resolve(reply_from, result.to_owned()) .resolve(
RuntimeAgentKey::new(project_id(), reply_from),
result.to_owned(),
)
.expect("structured completion ok"); .expect("structured completion ok");
timeout(TEST_GUARD, ask) timeout(TEST_GUARD, ask)
.await .await
@ -3933,6 +3957,7 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
let convs = TestConversations::new(); let convs = TestConversations::new();
let expected = convs let expected = convs
.resolve( .resolve(
project_id(),
ConversationParty::agent(a), ConversationParty::agent(a),
ConversationParty::agent(aid(1)), ConversationParty::agent(aid(1)),
) )
@ -4168,6 +4193,7 @@ struct NoopResumer;
impl AgentResumer for NoopResumer { impl AgentResumer for NoopResumer {
async fn resume( async fn resume(
&self, &self,
_project_id: ProjectId,
_agent_id: AgentId, _agent_id: AgentId,
_node_id: NodeId, _node_id: NodeId,
_conversation_id: Option<String>, _conversation_id: Option<String>,
@ -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). // Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
assert!( 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" "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). // La session est désormais vivante dans le registre partagé (insérée par le launcher).
assert!( 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" "la session auto-lancée est enregistrée dans StructuredSessions"
); );
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal). // 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_eq!(fx.factory.start_count(), 1, "headless session started");
assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped"); assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped");
assert!( 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" "target now has a structured session"
); );
assert!( 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" "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 agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
let resets_at_ms = 9_999_999; let resets_at_ms = 9_999_999;
fx.structured.insert( fx.structured.insert_in_project(
project_id(),
Arc::new(RateLimitedSession { Arc::new(RateLimitedSession {
id: sid(9700), id: sid(9700),
conversation_id: Some("engine-target".to_owned()), 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() { async fn ask_structured_rate_limit_without_conversation_uses_human_fallback_no_resume() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md"); let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona")); let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
fx.structured.insert( fx.structured.insert_in_project(
project_id(),
Arc::new(RateLimitedSession { Arc::new(RateLimitedSession {
id: sid(9701), id: sid(9701),
conversation_id: None, conversation_id: None,

View File

@ -418,7 +418,10 @@ async fn save_then_list_then_delete() {
let store = FakeProfileStore::default(); let store = FakeProfileStore::default();
let save = SaveProfile::new(Arc::new(store.clone())); let save = SaveProfile::new(Arc::new(store.clone()));
let list = ListProfiles::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 p = profile(1, "Claude", "claude");
let saved = save let saved = save
@ -532,7 +535,10 @@ async fn save_opencode_provider_profile_drops_stale_local_backend() {
.await .await
.unwrap(); .unwrap();
assert!(out.profile.opencode.is_none(), "stale local backend dropped"); assert!(
out.profile.opencode.is_none(),
"stale local backend dropped"
);
assert_eq!( assert_eq!(
out.profile.opencode_provider.as_ref().unwrap().provider_id, out.profile.opencode_provider.as_ref().unwrap().provider_id,
"anthropic" "anthropic"
@ -571,7 +577,13 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() {
}) })
.await .await
.unwrap(); .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!( assert_eq!(
secrets.get(&secret_ref).await.unwrap(), secrets.get(&secret_ref).await.unwrap(),
Some("sk-live-to-be-purged".to_owned()) Some("sk-live-to-be-purged".to_owned())
@ -707,8 +719,14 @@ async fn clone_opencode_profile_accepts_a_cloud_provider_seed() {
) )
.unwrap(), .unwrap(),
); );
assert!(cloud_seed.opencode.is_none(), "precondition: cloud seed has no local backend"); assert!(
assert!(cloud_seed.opencode_provider.is_some(), "precondition: cloud seed has a provider"); 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())) SaveProfile::new(Arc::new(store.clone()))
.execute(SaveProfileInput { .execute(SaveProfileInput {
@ -787,12 +805,13 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
None, None,
) )
.unwrap(); .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())) SaveProfile::new(Arc::new(store.clone()))
.execute(SaveProfileInput { .execute(SaveProfileInput { profile: squatter })
profile: squatter,
})
.await .await
.unwrap(); .unwrap();

View File

@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use application::{AgentResumer, AppError, SessionLimitService, RESUME_PROMPT}; 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::ports::{Clock, EventBus, EventStream, ScheduledTask, Scheduler};
use domain::DomainEvent; use domain::DomainEvent;
use uuid::Uuid; use uuid::Uuid;
@ -24,6 +24,9 @@ fn aid(n: u128) -> AgentId {
fn nid(n: u128) -> NodeId { fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n)) NodeId::from_uuid(Uuid::from_u128(n))
} }
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fakes des ports // Fakes des ports
@ -128,6 +131,7 @@ impl FakeResumer {
impl AgentResumer for FakeResumer { impl AgentResumer for FakeResumer {
async fn resume( async fn resume(
&self, &self,
_project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
conversation_id: Option<String>, conversation_id: Option<String>,
@ -185,8 +189,13 @@ const NOW: i64 = 1_700_000_000_000;
fn on_rate_limited_future_arms_and_emits_in_order() { fn on_rate_limited_future_arms_and_emits_in_order() {
let env = env_at(NOW); let env = env_at(NOW);
let reset = NOW + 60_000; let reset = NOW + 60_000;
env.service env.service.on_rate_limited(
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset)); 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. // Exactement un arm, avec la bonne échéance et la bonne tâche.
let armed = env.scheduler.armed(); let armed = env.scheduler.armed();
@ -195,6 +204,7 @@ fn on_rate_limited_future_arms_and_emits_in_order() {
assert_eq!( assert_eq!(
armed[0].1, armed[0].1,
ScheduledTask::ResumeAgent { ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1), agent_id: aid(1),
node_id: nid(2), node_id: nid(2),
conversation_id: Some("conv-1".to_owned()), 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 env = env_at(NOW);
let past = NOW - 60_000; let past = NOW - 60_000;
env.service 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(); let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1); 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() { fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
let env = env_at(NOW); let env = env_at(NOW);
env.service 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!( assert!(
env.scheduler.armed().is_empty(), 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 env = env_at(NOW);
let reset1 = NOW + 60_000; let reset1 = NOW + 60_000;
let reset2 = NOW + 120_000; let reset2 = NOW + 120_000;
env.service env.service.on_rate_limited(
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset1)); pid(1),
env.service aid(1),
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset2)); 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. // Deux arms (un par signal), ids distincts.
let issued = env.scheduler.issued(); 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); let env = env_at(NOW);
// Arme d'abord (pour prouver que l'entrée est ensuite retirée). // Arme d'abord (pour prouver que l'entrée est ensuite retirée).
env.service.on_rate_limited( env.service.on_rate_limited(
pid(1),
aid(1), aid(1),
nid(2), nid(2),
Some("conv-1".to_owned()), Some("conv-1".to_owned()),
@ -333,6 +354,7 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
); );
let task = ScheduledTask::ResumeAgent { let task = ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1), agent_id: aid(1),
node_id: nid(2), node_id: nid(2),
conversation_id: Some("conv-1".to_owned()), 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); env.resumer.set_fail(true);
let task = ScheduledTask::ResumeAgent { let task = ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1), agent_id: aid(1),
node_id: nid(2), node_id: nid(2),
conversation_id: None, 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() { fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
let env = env_at(NOW); let env = env_at(NOW);
env.service.on_rate_limited( env.service.on_rate_limited(
pid(1),
aid(1), aid(1),
nid(2), nid(2),
Some("conv-1".to_owned()), 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() { fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
let env = env_at(NOW); let env = env_at(NOW);
env.service.on_rate_limited( env.service.on_rate_limited(
pid(1),
aid(1), aid(1),
nid(2), nid(2),
Some("conv-1".to_owned()), 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 env = env_at(NOW);
let reset = NOW + 90_000; let reset = NOW + 90_000;
env.service 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. // Exactement un arm, bonne échéance, bonne tâche.
let armed = env.scheduler.armed(); let armed = env.scheduler.armed();
@ -493,6 +518,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
assert_eq!( assert_eq!(
armed[0].1, armed[0].1,
ScheduledTask::ResumeAgent { ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1), agent_id: aid(1),
node_id: nid(2), node_id: nid(2),
conversation_id: Some("conv-1".to_owned()), 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() { fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW); let env = env_at(NOW);
let past = NOW - 30_000; 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(); let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1); 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() { fn confirm_human_resume_after_auto_dedups_single_active_arm() {
let env = env_at(NOW); let env = env_at(NOW);
env.service.on_rate_limited( env.service.on_rate_limited(
pid(1),
aid(1), aid(1),
nid(2), nid(2),
Some("conv-1".to_owned()), Some("conv-1".to_owned()),
Some(NOW + 60_000), Some(NOW + 60_000),
); );
env.service env.service.confirm_human_resume(
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000); pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 120_000,
);
let issued = env.scheduler.issued(); let issued = env.scheduler.issued();
assert_eq!( assert_eq!(
@ -600,9 +633,15 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
#[test] #[test]
fn auto_after_confirm_human_resume_dedups_single_active_arm() { fn auto_after_confirm_human_resume_dedups_single_active_arm() {
let env = env_at(NOW); let env = env_at(NOW);
env.service env.service.confirm_human_resume(
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 60_000,
);
env.service.on_rate_limited( env.service.on_rate_limited(
pid(1),
aid(1), aid(1),
nid(2), nid(2),
Some("conv-1".to_owned()), Some("conv-1".to_owned()),
@ -644,8 +683,13 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
#[test] #[test]
fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() { fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() {
let env = env_at(NOW); let env = env_at(NOW);
env.service env.service.confirm_human_resume(
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000); pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 60_000,
);
let issued = env.scheduler.issued(); let issued = env.scheduler.issued();
assert!( assert!(
@ -670,13 +714,18 @@ fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() {
let reset = NOW + 60_000; let reset = NOW + 60_000;
let auto = env_at(NOW); let auto = env_at(NOW);
auto.service auto.service.on_rate_limited(
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset)); pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(reset),
);
let human = env_at(NOW); let human = env_at(NOW);
human human
.service .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. // Même séquence d'events.
assert_eq!( assert_eq!(

View File

@ -13,7 +13,7 @@ use std::sync::Mutex;
use async_trait::async_trait; use async_trait::async_trait;
use application::{drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome}; 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::input::{AgentBusyState, InputMediator};
use domain::mailbox::{PendingReply, Ticket}; use domain::mailbox::{PendingReply, Ticket};
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
@ -24,6 +24,10 @@ fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n)) 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. // Fake AgentSession : `send` rejoue une liste fixe d'événements.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -57,17 +61,17 @@ struct RecordingMediator {
calls: Mutex<Vec<&'static str>>, calls: Mutex<Vec<&'static str>>,
} }
impl InputMediator for RecordingMediator { 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())) PendingReply::new(Box::pin(std::future::pending()))
} }
fn preempt(&self, _agent: AgentId) {} fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, _agent: AgentId) { fn mark_idle(&self, _agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push("idle"); self.calls.lock().unwrap().push("idle");
} }
fn mark_alive(&self, _agent: AgentId) { fn mark_alive(&self, _agent: RuntimeAgentKey) {
self.calls.lock().unwrap().push("alive"); self.calls.lock().unwrap().push("alive");
} }
fn busy_state(&self, _agent: AgentId) -> AgentBusyState { fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState {
AgentBusyState::Idle AgentBusyState::Idle
} }
} }
@ -86,7 +90,7 @@ async fn outcome_rate_limited_some_without_final_is_graceful() {
}], }],
}; };
let mediator = RecordingMediator::default(); 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 .await
.expect("un tour limité est une fin gracieuse, pas une erreur"); .expect("un tour limité est une fin gracieuse, pas une erreur");
assert_eq!( assert_eq!(
@ -109,7 +113,7 @@ async fn outcome_rate_limited_none_without_final_is_graceful() {
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }], events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
}; };
let mediator = RecordingMediator::default(); 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 .await
.expect("fin gracieuse"); .expect("fin gracieuse");
assert_eq!(out, TurnOutcome::RateLimited { resets_at_ms: None }); 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 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 .await
.expect("ok"); .expect("ok");
assert_eq!(out, TurnOutcome::Completed("fini".to_owned())); 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() }], events: vec![ReplyEvent::TextDelta { text: "a".into() }],
}; };
let mediator = RecordingMediator::default(); 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 .await
.expect_err("flux tronqué sans limite ⇒ erreur"); .expect_err("flux tronqué sans limite ⇒ erreur");
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}"); 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 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 .await
.expect_err("limite ⇒ Io sur la signature historique"); .expect_err("limite ⇒ Io sur la signature historique");
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}"); assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
@ -197,7 +201,7 @@ async fn drain_with_readiness_nominal_still_completes() {
], ],
}; };
let mediator = RecordingMediator::default(); 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 .await
.expect("ok"); .expect("ok");
assert_eq!(content, "fini"); assert_eq!(content, "fini");

View File

@ -147,7 +147,7 @@ impl FakeLive {
} }
impl LiveAgentRegistry for 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) self.agents.lock().unwrap().contains(agent_id)
} }
fn is_node_live(&self, node_id: &NodeId) -> bool { fn is_node_live(&self, node_id: &NodeId) -> bool {

View File

@ -555,7 +555,7 @@ impl AgentSessionFactory for FakeFactory {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
use application::ProviderSessionProvider; use application::ProviderSessionProvider;
use domain::{ConversationId, ProviderSessionStore}; use domain::{ConversationId, ConversationParty, ProviderSessionStore};
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a /// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
/// `(conversation, provider_id) → resumable_id` map, observable after the launch. /// `(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)) NodeId::from_uuid(Uuid::from_u128(n))
} }
fn project_id() -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(1000))
}
fn project() -> Project { fn project() -> Project {
Project::new( Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)), project_id(),
"demo", "demo",
ProjectPath::new(ROOT).unwrap(), ProjectPath::new(ROOT).unwrap(),
RemoteRef::local(), RemoteRef::local(),
@ -734,7 +738,7 @@ fn seed_live_pty_session(
size, size,
); );
session.status = domain::SessionStatus::Running; 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. // La session est enregistrée dans le registre structuré, retrouvable par agent.
let registered = f let registered = f
.structured .structured
.session_for_agent(&f.agent.id) .session_for_agent_in_project(project_id(), &f.agent.id)
.expect("structured session registered"); .expect("structured session registered");
assert_eq!(registered.id(), sid(500), "session id is the factory's"); 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. // 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. // AgentLaunched publié avec l'id de session structurée.
assert_eq!( 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 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 // 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)` // moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(project, User, agent)`.
// dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`). let expected_pair = ConversationId::for_project_pair(
project_id(),
ConversationParty::User,
ConversationParty::agent(f.agent.id),
);
assert_eq!( assert_eq!(
out.assigned_conversation_id.as_deref(), 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" "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é. // 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é. // Session côté registre PTY, rien côté structuré.
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777))); assert_eq!(
assert!(f.structured.session_for_agent(&f.agent.id).is_none()); 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. // output.structured = None ; session PTY classique.
assert!( assert!(
@ -937,7 +959,8 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
"still a single live structured session" "still a single live structured session"
); );
assert_eq!( assert_eq!(
f.structured.node_for_agent(&f.agent.id), f.structured
.node_for_agent_in_project(project_id(), &f.agent.id),
Some(host), Some(host),
"session stays pinned on its host node A" "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.factory.start_count(), 1, "no second factory.start");
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn"); 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.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"); let desc = out.structured.expect("descriptor on rebind");
assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.session_id, sid(500), "same live session id");
assert_eq!(desc.node_id, host); assert_eq!(desc.node_id, host);
@ -1002,7 +1029,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
1, 1,
"still a single live structured session" "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"); let desc = out.structured.expect("descriptor on rebind");
assert_eq!(desc.session_id, sid(500), "same live session id"); assert_eq!(desc.session_id, sid(500), "same live session id");
assert_eq!(desc.node_id, target); 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) .start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None)
.await .await
.expect("seed structured session"); .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 // 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. // 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, relaunched.node_id, host,
"relaunch reopens in the same cell" "relaunch reopens in the same cell"
); );
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601))); assert_eq!(
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host)); 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!( assert_eq!(
f.structured.len(), f.structured.len(),
1, 1,
@ -1230,8 +1270,15 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
); );
assert_eq!(relaunched.id, sid(777)); assert_eq!(relaunched.id, sid(777));
// The relaunched session lives in the PTY registry, not the structured one. // 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_eq!(
assert!(f.structured.session_for_agent(&agent.id).is_none()); 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))); assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
} }

View File

@ -22,7 +22,9 @@ use async_trait::async_trait;
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions}; use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream}; 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; use uuid::Uuid;
// --- petits constructeurs déterministes ------------------------------------ // --- petits constructeurs déterministes ------------------------------------
@ -33,6 +35,9 @@ fn sid(n: u128) -> SessionId {
fn aid(n: u128) -> AgentId { fn aid(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n)) AgentId::from_uuid(Uuid::from_u128(n))
} }
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
fn nid(n: u128) -> NodeId { fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n)) NodeId::from_uuid(Uuid::from_u128(n))
} }
@ -129,7 +134,7 @@ fn structured_meta_for_session_resolves_agent_node_and_conversation() {
assert_eq!( assert_eq!(
reg.meta_for_session(&s), 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). // 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. // L'agent n'a pas de session vivante avant insertion.
let reg = StructuredSessions::new(); let reg = StructuredSessions::new();
let a = aid(10); 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()); assert!(reg.session_for_agent(&a).is_none());
reg.insert(fake(sid(1)), a, nid(100)); 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. // `session_for_agent` est non ambigu : il rend LA session de l'agent.
let resolved = reg.session_for_agent(&a).unwrap().id(); let resolved = reg.session_for_agent(&a).unwrap().id();
@ -200,14 +205,14 @@ fn structured_live_agent_registry_impl() {
let a = aid(10); let a = aid(10);
let n = nid(100); let n = nid(100);
assert!(!reg.is_agent_live(&a)); assert!(!reg.is_agent_live(pid(0), &a));
assert!(!reg.is_node_live(&n)); assert!(!reg.is_node_live(&n));
reg.insert(fake(sid(1)), a, 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_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))); assert!(!reg.is_node_live(&nid(999)));
// is_node_live suit le rebind (la cellule vivante change). // 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`. /// Insère un agent PTY dans `TerminalSessions`.
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) { 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( let session = TerminalSession::starting(
s, s,
n, n,
@ -240,7 +256,7 @@ fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
SessionKind::Agent { agent_id: a }, SessionKind::Agent { agent_id: a },
PtySize::new(24, 80).unwrap(), PtySize::new(24, 80).unwrap(),
); );
pty.insert(PtyHandle { session_id: s }, session); pty.insert_in_project(project_id, PtyHandle { session_id: s }, session);
} }
#[test] #[test]
@ -252,10 +268,10 @@ fn aggregator_agent_live_via_structured_only() {
let a = aid(10); let a = aid(10);
structured.insert(fake(sid(1)), a, nid(100)); 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!(agg.is_node_live(&nid(100)));
assert_eq!(agg.session_id_for_agent(&a), Some(sid(1))); assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(1)));
assert_eq!(agg.node_for_agent(&a), Some(nid(100))); assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(100)));
} }
#[test] #[test]
@ -267,10 +283,10 @@ fn aggregator_agent_live_via_pty_only() {
let a = aid(20); let a = aid(20);
insert_pty(&pty, sid(2), a, nid(200)); 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!(agg.is_node_live(&nid(200)));
assert_eq!(agg.session_id_for_agent(&a), Some(sid(2))); assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(2)));
assert_eq!(agg.node_for_agent(&a), Some(nid(200))); assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(200)));
} }
#[test] #[test]
@ -280,11 +296,11 @@ fn aggregator_agent_absent_from_both_is_not_live() {
let agg = LiveSessions::new(pty, structured); let agg = LiveSessions::new(pty, structured);
let a = aid(30); 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.is_node_live(&nid(300)));
assert!(agg.session_id_for_agent(&a).is_none()); assert!(agg.session_id_for_agent(pid(0), &a).is_none());
assert!(agg.node_for_agent(&a).is_none()); assert!(agg.node_for_agent(pid(0), &a).is_none());
assert!(agg.live_agents().is_empty()); assert!(agg.live_agents(pid(0)).is_empty());
} }
#[test] #[test]
@ -296,7 +312,7 @@ fn aggregator_live_agents_concatenates_pty_then_structured() {
insert_pty(&pty, sid(1), aid(10), nid(100)); insert_pty(&pty, sid(1), aid(10), nid(100));
structured.insert(fake(sid(2)), aid(20), nid(200)); 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"); assert_eq!(all.len(), 2, "both registries contribute");
// PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur). // PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur).
assert_eq!(all[0], (aid(10), nid(100), sid(1))); 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)); structured.insert(fake(sid(2)), struct_agent, nid(200));
// Agent PTY : résolu par le registre PTY. // Agent PTY : résolu par le registre PTY.
assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1))); assert_eq!(agg.session_id_for_agent(pid(0), &pty_agent), Some(sid(1)));
assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100))); assert_eq!(agg.node_for_agent(pid(0), &pty_agent), Some(nid(100)));
// Agent structuré : fallback sur le registre structuré. // Agent structuré : fallback sur le registre structuré.
assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2))); assert_eq!(
assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200))); 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. // is_node_live : OR sur les deux.
assert!(agg.is_node_live(&nid(100))); assert!(agg.is_node_live(&nid(100)));
assert!(agg.is_node_live(&nid(200))); assert!(agg.is_node_live(&nid(200)));
assert!(!agg.is_node_live(&nid(999))); 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))]);
}

View File

@ -25,7 +25,8 @@ use domain::{
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId, BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId,
ConversationLog, ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource, ConversationLog, ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource,
ManifestEntry, MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize, 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; use uuid::Uuid;
@ -129,21 +130,21 @@ impl FakeInput {
} }
impl InputMediator for FakeInput { impl InputMediator for FakeInput {
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply { fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
let fut: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>> = let fut: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>> =
Box::pin(async { Err(MailboxError::Cancelled) }); Box::pin(async { Err(MailboxError::Cancelled) });
PendingReply::new(fut) 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 self.busy
.lock() .lock()
.unwrap() .unwrap()
.get(&agent) .get(&agent.agent_id)
.copied() .copied()
.unwrap_or(AgentBusyState::Idle) .unwrap_or(AgentBusyState::Idle)
} }
@ -261,11 +262,11 @@ impl FakeQueue {
} }
impl AgentQueueSnapshot for FakeQueue { impl AgentQueueSnapshot for FakeQueue {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> { fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot> {
self.queues self.queues
.lock() .lock()
.unwrap() .unwrap()
.get(&agent) .get(&agent.agent_id)
.cloned() .cloned()
.unwrap_or_default() .unwrap_or_default()
} }
@ -446,7 +447,8 @@ fn insert_pty(
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
) { ) {
sessions.insert( sessions.insert_in_project(
project().id,
PtyHandle { session_id }, PtyHandle { session_id },
TerminalSession::starting( TerminalSession::starting(
session_id, 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() { async fn workstate_attaches_live_structured_session_to_manifest_agent() {
let a = agent(10, "alpha"); let a = agent(10, "alpha");
let f = fixture(std::slice::from_ref(&a)); 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 let out = f
.usecase .usecase

View File

@ -156,7 +156,8 @@ fn insert_pty(
agent_id: AgentId, agent_id: AgentId,
node_id: NodeId, node_id: NodeId,
) { ) {
sessions.insert( sessions.insert_in_project(
project().id,
PtyHandle { session_id }, PtyHandle { session_id },
TerminalSession::starting( TerminalSession::starting(
session_id, session_id,
@ -175,7 +176,8 @@ fn insert_structured(
node_id: NodeId, node_id: NodeId,
) -> Arc<AtomicBool> { ) -> Arc<AtomicBool> {
let flag = Arc::new(AtomicBool::new(false)); let flag = Arc::new(AtomicBool::new(false));
sessions.insert( sessions.insert_in_project(
project().id,
Arc::new(FakeSession { Arc::new(FakeSession {
id: session_id, id: session_id,
shutdown_called: Arc::clone(&flag), 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.node_id, nid(200), "view rebound to the new node");
assert_eq!(out.kind, LiveSessionKind::Pty); assert_eq!(out.kind, LiveSessionKind::Pty);
// The registry reflects the new host node, same session. // The registry reflects the new host node, same session.
assert_eq!(f.pty.node_for_agent(&a), Some(nid(200))); assert_eq!(
assert_eq!(f.pty.session_for_agent(&a), Some(sid(1))); 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] #[test]
@ -232,7 +240,10 @@ fn attach_structured_rebinds_node() {
assert_eq!(out.session_id, sid(2)); assert_eq!(out.session_id, sid(2));
assert_eq!(out.node_id, nid(300)); assert_eq!(out.node_id, nid(300));
assert_eq!(out.kind, LiveSessionKind::Structured); 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] #[test]
@ -309,7 +320,7 @@ async fn stop_pty_kills_and_removes_session() {
// Delegated to the close primitive: process killed and registry emptied. // Delegated to the close primitive: process killed and registry emptied.
assert_eq!(f.pty_port.kills(), vec![sid(1)]); assert_eq!(f.pty_port.kills(), vec![sid(1)]);
assert!(f.pty.is_empty(), "live session removed from the registry"); 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] #[tokio::test]
@ -331,7 +342,11 @@ async fn stop_structured_shuts_down_and_removes_session() {
assert_eq!(out.kind, LiveSessionKind::Structured); assert_eq!(out.kind, LiveSessionKind::Structured);
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called"); assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
assert!(f.structured.is_empty(), "live session removed"); 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. // No PTY was touched.
assert!(f.pty_port.kills().is_empty()); assert!(f.pty_port.kills().is_empty());
} }

View File

@ -29,22 +29,21 @@ use application::{
ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListAgentsInput, ListDevices, ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories,
ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins, ListModelServers, ListOpenCodeProviders, ListPluginRuntimeContributions, ListPlugins,
ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, ListProfiles, ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates,
LiveAgentRegistry, LiveSessions, LiveAgentRegistry, LiveSessions, LiveStateLeanProvider, LiveStateProvider,
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime, LiveStateReadProvider, LoadLayout, McpRuntime, McpToolPermissionCatalogue, MoveTabToNewWindow,
McpToolPermissionCatalogue, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, MutateLayout, OnnxModelView, OpenProject, OpenTerminal, OpenTicketAssistant,
OpenTerminal, OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice, OrchestratorService, PairAttemptLimiter, PairDevice, PermissionProjectorRegistry,
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext, ProposeContext, ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue,
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadIssueCarnet, ReadMcpToolPermissions, ReadMemory, ReadMemoryIndex, ReadProjectContext,
ReadMemoryIndex, ReadProjectContext, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReadSkill, ReadTemplate, RecallMemory, ReconcileLayouts, ReconcileLiveState,
ReconcileLiveState, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, ReconcileLiveStateInput, ReconcilePluginMcpServers, RecordTurn, RecordTurnProvider,
RecordTurnProvider, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReferenceProfiles, RenameDevice, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal,
ReorderSprints, ResizeTerminal, ResolveAgentPermissions, ResolveMemoryLinks, ResolveAgentPermissions, ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask,
RestoreOpenWindows, RetryBackgroundTask, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, ReviewPluginPackage, RevokeAllDevices, RevokeDevice, RotateConversationLog,
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveEmbedderProfile, SaveModelServer, SaveOpenCodeProviderProfile, SaveProfile,
SaveProfile, SessionLimitService, SessionLimitService, SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows,
SetActiveLayout, SetPluginEnabled, SnapshotOpenWindows, SnapshotRunningAgents, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode,
SpawnBackgroundCommand, StopLiveAgent, StructuredRoutingMode, StructuredSessions, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, TouchDevice,
UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues, UnassignSkillFromAgent, UnassignTicketFromSprint, UninstallPlugin, UnlinkIssues,
UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue, UpdateAgentContext, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateIssue,
UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
@ -85,8 +84,7 @@ use infrastructure::{
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore,
FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore, FsPluginPackageStore, FsPluginRegistryStore, FsProfileStore, FsProjectStore,
FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore, FsProviderSessionStore, FsSecretStore, FsSkillStore, FsSprintStore, FsTemplateStore,
FsWindowStateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, InMemoryPairAttemptLimiter, LlamaCppRuntime, LocalFileSystem, LocalManagedProcess,
LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle,
@ -696,7 +694,10 @@ impl WakeSessionProvider for AppWakeSessionProvider {
project: &Project, project: &Project,
agent: AgentId, agent: AgentId,
) -> Result<Arc<dyn AgentSession>, WakeError> { ) -> Result<Arc<dyn AgentSession>, 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); return Ok(session);
} }
@ -715,7 +716,7 @@ impl WakeSessionProvider for AppWakeSessionProvider {
.map_err(|err| WakeError::Session(err.to_string()))?; .map_err(|err| WakeError::Session(err.to_string()))?;
self.structured_sessions self.structured_sessions
.session_for_agent(&agent) .session_for_agent_in_project(project.id, &agent)
.ok_or_else(|| { .ok_or_else(|| {
WakeError::Session(format!( WakeError::Session(format!(
"agent {agent} has no structured session after background wake launch" "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). /// Contexte minimal de **relance** d'un agent (LS7, ARCHITECTURE §21.5).
/// ///
/// [`AgentResumer::resume`] et [`ScheduledTask::ResumeAgent`] ne portent **pas** le /// [`AgentResumer::resume`] ne porte pas le `Project` complet ni la taille de la
/// `Project` ni la taille de la cellule, alors que [`LaunchAgentInput`] les exige. /// cellule, alors que [`LaunchAgentInput`] les exige. La commande `launch_agent`
/// La commande `launch_agent` (seul endroit où ces faits sont en main) alimente ce /// (seul endroit où ces faits sont en main) alimente ce contexte par clé runtime
/// contexte par `agent_id` ; [`AppAgentResumer`] le relit à l'échéance pour /// `(project_id, agent_id)` ; [`AppAgentResumer`] le relit à l'échéance pour
/// recomposer un lancement complet. /// recomposer un lancement complet sans collision entre projets.
#[derive(Clone)] #[derive(Clone)]
pub struct ResumeContext { pub struct ResumeContext {
/// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`). /// Le projet hôte de l'agent (pour recomposer `LaunchAgentInput`).
@ -760,10 +761,10 @@ pub struct ResumeContext {
pub cols: u16, 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é /// `launch_agent`). Le **même** `Arc` est injecté dans [`AppAgentResumer`] et conservé
/// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement. /// sur [`BackendCore`] pour que la commande l'alimente à chaque lancement.
pub type ResumeContexts = Arc<Mutex<HashMap<AgentId, ResumeContext>>>; pub type ResumeContexts = Arc<Mutex<HashMap<domain::RuntimeAgentKey, ResumeContext>>>;
/// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de /// Implémente le port applicatif [`AgentResumer`] (LS7) **par-dessus** le mécanisme de
/// lancement existant ([`LaunchAgent`]). /// lancement existant ([`LaunchAgent`]).
@ -789,6 +790,7 @@ struct AppAgentResumer {
impl AgentResumer for AppAgentResumer { impl AgentResumer for AppAgentResumer {
async fn resume( async fn resume(
&self, &self,
project_id: ProjectId,
agent_id: AgentId, agent_id: AgentId,
node_id: domain::NodeId, node_id: domain::NodeId,
conversation_id: Option<String>, conversation_id: Option<String>,
@ -796,11 +798,10 @@ impl AgentResumer for AppAgentResumer {
) -> Result<(), AppError> { ) -> Result<(), AppError> {
// Repli propre (jamais de panique) : sans contexte de relance connu, on ne // Repli propre (jamais de panique) : sans contexte de relance connu, on ne
// reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié. // reprend pas à l'aveugle. L'erreur remonte ⇒ `AgentResumed` n'est pas publié.
let ctx = self let ctx = self.contexts.lock().ok().and_then(|m| {
.contexts m.get(&domain::RuntimeAgentKey::new(project_id, agent_id))
.lock() .cloned()
.ok() });
.and_then(|m| m.get(&agent_id).cloned());
let Some(ctx) = ctx else { let Some(ctx) = ctx else {
return Err(AppError::NotFound(format!( return Err(AppError::NotFound(format!(
"resume context for agent {agent_id}" "resume context for agent {agent_id}"
@ -818,6 +819,7 @@ impl AgentResumer for AppAgentResumer {
requester: agent_id.to_string(), requester: agent_id.to_string(),
}); });
let project_id = ctx.project.id;
self.launch_agent self.launch_agent
.execute(LaunchAgentInput { .execute(LaunchAgentInput {
project: ctx.project, project: ctx.project,
@ -840,7 +842,9 @@ impl AgentResumer for AppAgentResumer {
"IdeA", "IdeA",
resume_prompt, resume_prompt,
); );
let _ = self.input_mediator.enqueue(agent_id, ticket); let _ = self
.input_mediator
.enqueue(domain::RuntimeAgentKey::new(project_id, agent_id), ticket);
Ok(()) Ok(())
} }
@ -1200,7 +1204,7 @@ pub struct BackendCore {
/// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est /// (structuré, `agent_send`) et niveau 2 (PTY, `launch_agent`) ; sa reprise auto est
/// annulable via la commande `cancel_resume`. /// annulable via la commande `cancel_resume`.
pub session_limit_service: Arc<SessionLimitService>, pub session_limit_service: Arc<SessionLimitService>,
/// 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 /// la commande `launch_agent` y dépose le `Project`/taille du dernier lancement pour
/// que la reprise auto puisse recomposer un `LaunchAgentInput` complet. /// que la reprise auto puisse recomposer un `LaunchAgentInput` complet.
pub resume_contexts: ResumeContexts, 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 /// Médiateur d'entrée partagé, capturé pour câbler le callback `turn_ended` du
/// [`turn_watcher`](Self::turn_watcher) à l'armement. /// [`turn_watcher`](Self::turn_watcher) à l'armement.
pub turn_watch_input: Arc<dyn domain::input::InputMediator>, pub turn_watch_input: Arc<dyn domain::input::InputMediator>,
/// Handles des watches de fin-de-tour vivants, par agent. (Re)lancer un agent /// Handles des watches de fin-de-tour vivants, par clé runtime. (Re)lancer un agent
/// **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ; fermer/arrêter /// dans un projet **remplace** son handle (l'ancien est droppé ⇒ polling arrêté) ;
/// l'agent le retire. `Mutex` car launch/stop y accèdent concurremment. /// fermer/arrêter l'agent le retire. `Mutex` car launch/stop y accèdent concurremment.
pub turn_watch_handles: Mutex<HashMap<AgentId, Box<dyn domain::ports::TurnWatchHandle>>>, pub turn_watch_handles:
Mutex<HashMap<domain::RuntimeAgentKey, Box<dyn domain::ports::TurnWatchHandle>>>,
/// Port `FileSystem` partagé, conservé pour bâtir la **sonde d'activité** du rendez-vous /// 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 /// `idea_ask_agent` (octets cumulés des transcripts de la cible). Même port que
/// l'inspecteur et le turn-watcher. /// l'inspecteur et le turn-watcher.
@ -2235,7 +2240,10 @@ impl BackendCore {
created_at_ms: clock_for_items.now_millis().max(0) as u64, created_at_ms: clock_for_items.now_millis().max(0) as u64,
correlation_id: Some(ready.task_id.to_string()), 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 => { Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
application::diag!( application::diag!(
"[background-task] completion deferred: task={} owner={} \ "[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 // 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). // composition root est la seule à connaître la frontière infra↔domaine).
let service_for_ready = Arc::clone(&self.orchestrator_service); let service_for_ready = Arc::clone(&self.orchestrator_service);
let ready_project = project.clone();
let ready_sink: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(move |requester: &str| { let ready_sink: Arc<dyn Fn(&str) + Send + Sync> = Arc::new(move |requester: &str| {
if let Ok(uuid) = Uuid::parse_str(requester) { 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( let handle = McpServerHandle::start(
@ -2875,6 +2885,7 @@ impl BackendCore {
/// no-op. `conversation_id` is diagnostic only. /// no-op. `conversation_id` is diagnostic only.
pub fn arm_turn_watch( pub fn arm_turn_watch(
&self, &self,
project_id: ProjectId,
project_root: &domain::project::ProjectPath, project_root: &domain::project::ProjectPath,
agent_id: AgentId, agent_id: AgentId,
profile: &AgentProfile, profile: &AgentProfile,
@ -2894,21 +2905,22 @@ impl BackendCore {
}; };
let input = Arc::clone(&self.turn_watch_input); let input = Arc::clone(&self.turn_watch_input);
// Callback invoked from the watcher's polling task (no lock held here). // 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 let handle = self
.turn_watcher .turn_watcher
.watch(agent_id, conversation_id, cwd, on_turn_end); .watch(agent_id, conversation_id, cwd, on_turn_end);
if let Ok(mut map) = self.turn_watch_handles.lock() { if let Ok(mut map) = self.turn_watch_handles.lock() {
// Insert replaces (and drops) any prior handle ⇒ its polling task stops. // 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 /// Stops and removes the end-of-turn watcher of `agent_id` in `project_id` (close /
/// the stored handle stops its polling task. No-op if none is armed. /// stop). Dropping the stored handle stops its polling task. No-op if none is armed.
pub fn stop_turn_watch(&self, agent_id: AgentId) { pub fn stop_turn_watch(&self, project_id: ProjectId, agent_id: AgentId) {
if let Ok(mut map) = self.turn_watch_handles.lock() { 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}" "ask reply must be returned inline over the real loopback; got {result}"
); );
assert_eq!( assert_eq!(
mailbox.pending(&agent_id), mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
0, 0,
"structured ask must drain its accounting ticket" "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}" "ask reply must be returned inline over the real loopback (Codex target); got {result}"
); );
assert_eq!( assert_eq!(
mailbox.pending(&agent_id), mailbox.pending(&domain::RuntimeAgentKey::new(proj.id, agent_id)),
0, 0,
"structured ask must drain its accounting ticket" "structured ask must drain its accounting ticket"
); );

View File

@ -22,7 +22,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ids::{AgentId, SessionId}; use crate::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId};
/// Identifies one [`Conversation`]. /// 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 /// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon
/// commutative (XOR), stable et déterministe. /// commutative (XOR), stable et déterministe.
#[must_use] #[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()) { match (a.as_agent(), b.as_agent()) {
// User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`. // 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). // Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre).
(Some(x), Some(y)) => { (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)) Self::from_uuid(uuid::Uuid::from_u128(xor))
} }
// User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic. // User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic.
(None, None) => Self::from_uuid(uuid::Uuid::nil()), (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 { 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 /// Get-or-create: returns the thread for the pair `{a, b}`, opening it
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session. /// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
/// The same unordered pair always yields the same [`ConversationId`]. /// 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. /// Marks the conversation `id` `Live` with the given session reference.
fn bind_session(&self, id: ConversationId, session: SessionRef); fn bind_session(&self, id: ConversationId, session: SessionRef);

View File

@ -115,3 +115,34 @@ typed_id!(
/// Identifies a first-class background task. /// Identifies a first-class background task.
TaskId 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)
}
}

View File

@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::ids::{AgentId, TaskId}; use crate::ids::{AgentId, RuntimeAgentKey, TaskId};
use crate::mailbox::TicketId; use crate::mailbox::TicketId;
/// Default bounded inbox capacity per agent. /// Default bounded inbox capacity per agent.
@ -114,6 +114,8 @@ pub struct InboxReceipt {
pub item_id: TicketId, pub item_id: TicketId,
/// Target agent. /// Target agent.
pub agent_id: AgentId, pub agent_id: AgentId,
/// Runtime-scoped target agent.
pub runtime_key: RuntimeAgentKey,
/// FIFO depth after the operation. /// FIFO depth after the operation.
pub depth: usize, pub depth: usize,
/// Enqueue outcome. /// Enqueue outcome.
@ -126,6 +128,8 @@ pub struct InboxReceipt {
pub struct AgentInboxSnapshot { pub struct AgentInboxSnapshot {
/// Target agent. /// Target agent.
pub agent_id: AgentId, pub agent_id: AgentId,
/// Runtime-scoped target agent.
pub runtime_key: RuntimeAgentKey,
/// Number of queued inbox items. /// Number of queued inbox items.
pub depth: usize, pub depth: usize,
/// FIFO-ordered items. /// FIFO-ordered items.
@ -162,11 +166,15 @@ pub trait AgentInbox: Send + Sync {
/// ///
/// # Errors /// # Errors
/// [`InboxError`] when the item is invalid or a normal message overflows. /// [`InboxError`] when the item is invalid or a normal message overflows.
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError>; fn enqueue_message(
&self,
agent: RuntimeAgentKey,
item: InboxItem,
) -> Result<InboxReceipt, InboxError>;
/// Pops the next queued inbox item, if any. /// Pops the next queued inbox item, if any.
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem>; fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem>;
/// Returns the FIFO snapshot for `agent`. /// Returns the FIFO snapshot for `agent`.
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot; fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot;
} }

View File

@ -14,7 +14,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ids::AgentId; use crate::ids::{AgentId, RuntimeAgentKey};
use crate::mailbox::{PendingReply, Ticket, TicketId}; use crate::mailbox::{PendingReply, Ticket, TicketId};
use crate::ports::PtyHandle; 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 /// 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 /// text + submit sequence through the single PTY writer. The mediator stays the
/// authority of the FIFO/busy state and correlation only. /// 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 /// 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. /// 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 /// Default keeps compatibility for simple mediators; production overrides it to
/// suppress delivery. /// suppress delivery.
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply { fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
self.enqueue(agent, ticket) 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 /// 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 /// session for the target conversation. Default: no-op (a mediator that does not
/// own the delivery write). /// 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 /// Like [`InputMediator::bind_handle`], but also records the target's
/// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo /// [`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. /// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
/// The infra adapter overrides it to stash 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); 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 /// 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). /// branching on it (the orchestrator no longer falls back to its own PTY write).
#[must_use] #[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool { fn delivers_turn(&self, _agent: RuntimeAgentKey) -> bool {
false false
} }
@ -228,7 +233,7 @@ pub trait InputMediator: Send + Sync {
/// (chemin chaud, fallback sûr, zéro régression). /// (chemin chaud, fallback sûr, zéro régression).
/// ///
/// Default: no-op (a mediator that does not gate cold starts). /// 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 /// 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 /// 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é. /// watcher prompt-ready PTY ayant été supprimé.
/// ///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid). /// 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` /// 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 /// (`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 /// Default: no-op (médiateur sans notion de cellule front — tout passe par
/// l'événement, comportement historique). /// 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 /// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket. /// 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. /// 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 /// **End-of-turn** signal: the agent's transcript just recorded a completed turn
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a /// (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** /// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default: /// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances). /// [`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); 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 /// Default: no-op (a mediator that does not track liveness). The infra adapter
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an /// `MediatedInbox` overrides it to refresh `last_seen` and publish an
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement. /// `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 /// Declares the agent's **stall threshold** (its profile's
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows /// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
@ -298,10 +303,10 @@ pub trait InputMediator: Send + Sync {
/// zero regression). /// zero regression).
/// ///
/// Default: no-op (a mediator that does not track liveness). /// Default: no-op (a mediator that does not track liveness).
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {} fn set_stall_threshold(&self, _agent: RuntimeAgentKey, _stall_after_ms: Option<u32>) {}
/// The current [`AgentBusyState`] of `agent`. /// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState; fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState;
} }
#[cfg(test)] #[cfg(test)]

View File

@ -76,8 +76,8 @@ mod validation;
pub use error::DomainError; pub use error::DomainError;
pub use ids::{ pub use ids::{
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId, AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId, ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
}; };
pub use project::{Project, ProjectPath}; pub use project::{Project, ProjectPath};

View File

@ -30,7 +30,7 @@ use std::pin::Pin;
use std::task::{Context, Poll}; use std::task::{Context, Poll};
use crate::conversation::ConversationId; use crate::conversation::ConversationId;
use crate::ids::AgentId; use crate::ids::{AgentId, RuntimeAgentKey};
use crate::input::InputSource; use crate::input::InputSource;
/// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO. /// 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 /// 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. /// the current order (`0` = head). Pure read: the queue is left untouched.
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot>; fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot>;
} }
/// Identifies one queued [`Ticket`] within a target agent's mailbox. /// 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`] /// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
/// feeds a result to this ticket (once it reaches the head and is answered), or /// feeds a result to this ticket (once it reaches the head and is answered), or
/// to [`MailboxError::Cancelled`] if the reply channel closes first. /// 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`, /// Resolves the request at the **head** of `agent`'s FIFO with `result`,
/// waking its awaiting [`PendingReply`] and removing it from the queue. /// waking its awaiting [`PendingReply`] and removing it from the queue.
@ -294,7 +294,7 @@ pub trait AgentMailbox: Send + Sync {
/// # Errors /// # Errors
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an /// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
/// `idea_reply` with no matching ask in flight). /// `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 /// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the /// 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). /// (and, for the default, when `agent`'s queue is empty).
fn resolve_ticket( fn resolve_ticket(
&self, &self,
agent: AgentId, agent: RuntimeAgentKey,
_ticket_id: TicketId, _ticket_id: TicketId,
result: String, result: String,
) -> Result<(), MailboxError> { ) -> 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 /// 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. /// 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 /// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`]. /// 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 /// 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). /// 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); let _ = (agent, ticket_id);
} }
} }

View File

@ -658,6 +658,8 @@ pub enum ScheduledTask {
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté /// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique. /// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
ResumeAgent { ResumeAgent {
/// Projet hôte de l'agent à reprendre.
project_id: ProjectId,
/// L'agent à reprendre. /// L'agent à reprendre.
agent_id: AgentId, agent_id: AgentId,
/// La cellule (nœud du layout) qui héberge sa session. /// La cellule (nœud du layout) qui héberge sa session.

View File

@ -1415,9 +1415,7 @@ mod mcp_tests {
#[test] #[test]
fn opencode_provider_config_rejects_empty_fields() { fn opencode_provider_config_rejects_empty_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1"); let secret_ref = crate::ports::SecretRef::new("secret-1");
assert!( assert!(OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err());
OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()
);
assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err()); assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err());
} }
@ -1448,7 +1446,12 @@ mod mcp_tests {
#[test] #[test]
fn profile_with_opencode_provider_round_trips_camelcase() { 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() let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode) .with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode_provider(provider.clone()); .with_opencode_provider(provider.clone());
@ -1469,7 +1472,12 @@ mod mcp_tests {
None, None,
) )
.unwrap(); .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() let only_local = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode) .with_structured_adapter(StructuredAdapter::OpenCode)
@ -1504,7 +1512,12 @@ mod mcp_tests {
None, None,
) )
.unwrap(); .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. // Setting the cloud provider clears a previously-set local one.
let cloud_wins = profile_without_mcp() let cloud_wins = profile_without_mcp()
@ -1513,7 +1526,10 @@ mod mcp_tests {
.with_opencode_provider(cloud.clone()); .with_opencode_provider(cloud.clone());
assert!(cloud_wins.opencode_backend_is_consistent()); assert!(cloud_wins.opencode_backend_is_consistent());
assert!(cloud_wins.opencode.is_none(), "stale local backend dropped"); 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. // Setting the local provider clears a previously-set cloud one.
let local_wins = profile_without_mcp() let local_wins = profile_without_mcp()
@ -1521,13 +1537,21 @@ mod mcp_tests {
.with_opencode_provider(cloud) .with_opencode_provider(cloud)
.with_opencode(local); .with_opencode(local);
assert!(local_wins.opencode_backend_is_consistent()); 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()); assert!(local_wins.opencode.is_some());
} }
#[test] #[test]
fn opencode_provider_config_serialises_no_local_model_server_id_leak() { 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"); let json = serde_json::to_string(&config).expect("serialise");
assert!(!json.contains("localModelServerId")); assert!(!json.contains("localModelServerId"));
assert!(!json.contains("baseURL")); assert!(!json.contains("baseURL"));

View File

@ -195,7 +195,10 @@ pub fn start_background_ready_inbox_bridge(
created_at_ms: now_ms(), created_at_ms: now_ms(),
correlation_id: Some(ready.task_id.to_string()), 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 => { Ok(receipt) if receipt.status == InboxReceiptStatus::Deferred => {
application::diag!( application::diag!(
"[background-task] completion deferred: task={} owner={} queue_depth={}", "[background-task] completion deferred: task={} owner={} queue_depth={}",

View File

@ -17,6 +17,7 @@ use domain::conversation::{
Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession, Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession,
SessionRef, SessionRef,
}; };
use domain::ids::ProjectId;
/// Order-insensitive key for a conversation pair `{a, b}`. /// Order-insensitive key for a conversation pair `{a, b}`.
/// ///
@ -47,7 +48,7 @@ pub struct InMemoryConversationRegistry {
#[derive(Default)] #[derive(Default)]
struct Inner { struct Inner {
by_id: HashMap<ConversationId, Conversation>, by_id: HashMap<ConversationId, Conversation>,
by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>, by_pair: HashMap<(ProjectId, ConversationParty, ConversationParty), ConversationId>,
} }
impl InMemoryConversationRegistry { impl InMemoryConversationRegistry {
@ -79,10 +80,16 @@ impl InMemoryConversationRegistry {
} }
impl ConversationRegistry for 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 key = pair_key(a, b);
let scoped_key = (project_id, key.0, key.1);
let mut inner = self.lock(); 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. // Existing thread for this pair — return its current snapshot.
return inner return inner
.by_id .by_id
@ -97,10 +104,10 @@ impl ConversationRegistry for InMemoryConversationRegistry {
// the persistence key stable and aligned with `LaunchAgent` (P8a) / the // the persistence key stable and aligned with `LaunchAgent` (P8a) / the
// `resolve_conversation` fallback. The pair is valid by construction at call // `resolve_conversation` fallback. The pair is valid by construction at call
// sites; `try_new` still guards the invariants. // 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) let conv = Conversation::try_new(id, key.0, key.1)
.expect("pair_key yields a valid distinct/≤1-user pair"); .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()); inner.by_id.insert(id, conv.clone());
conv conv
} }
@ -136,14 +143,18 @@ mod tests {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n))) ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
} }
fn project(n: u128) -> ProjectId {
ProjectId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test] #[test]
fn resolve_is_lazy_get_or_create() { fn resolve_is_lazy_get_or_create() {
let reg = InMemoryConversationRegistry::new(); let reg = InMemoryConversationRegistry::new();
assert!(reg.is_empty()); 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); assert_eq!(reg.len(), 1);
// Same pair ⇒ same id, no new conversation created. // 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!(c.id, c2.id);
assert_eq!(reg.len(), 1); assert_eq!(reg.len(), 1);
} }
@ -151,8 +162,8 @@ mod tests {
#[test] #[test]
fn same_pair_unordered_yields_same_id() { fn same_pair_unordered_yields_same_id() {
let reg = InMemoryConversationRegistry::new(); let reg = InMemoryConversationRegistry::new();
let c1 = reg.resolve(agent(1), agent(2)); let c1 = reg.resolve(project(1), agent(1), agent(2));
let c2 = reg.resolve(agent(2), agent(1)); // swapped order let c2 = reg.resolve(project(1), agent(2), agent(1)); // swapped order
assert_eq!(c1.id, c2.id, "unordered pair identity"); assert_eq!(c1.id, c2.id, "unordered pair identity");
assert_eq!(reg.len(), 1); assert_eq!(reg.len(), 1);
} }
@ -160,8 +171,8 @@ mod tests {
#[test] #[test]
fn distinct_pairs_get_distinct_ids() { fn distinct_pairs_get_distinct_ids() {
let reg = InMemoryConversationRegistry::new(); let reg = InMemoryConversationRegistry::new();
let user_b = reg.resolve(ConversationParty::User, agent(2)); let user_b = reg.resolve(project(1), ConversationParty::User, agent(2));
let a_b = reg.resolve(agent(1), 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_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads");
assert_eq!(reg.len(), 2); assert_eq!(reg.len(), 2);
} }
@ -169,14 +180,14 @@ mod tests {
#[test] #[test]
fn fresh_resolve_is_dormant() { fn fresh_resolve_is_dormant() {
let reg = InMemoryConversationRegistry::new(); 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); assert_eq!(c.session, ConversationSession::Dormant);
} }
#[test] #[test]
fn bind_session_makes_it_live_then_suspend_restores_dormant() { fn bind_session_makes_it_live_then_suspend_restores_dormant() {
let reg = InMemoryConversationRegistry::new(); 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))); let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99)));
reg.bind_session(c.id, sref); reg.bind_session(c.id, sref);
let live = reg.get(c.id).unwrap(); 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. // l'IDE : la même paire User↔Agent doit produire le **même** id.
let a = agent(7); let a = agent(7);
let first = InMemoryConversationRegistry::new() let first = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, a) .resolve(project(1), ConversationParty::User, a)
.id; .id;
let second = InMemoryConversationRegistry::new() let second = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, a) .resolve(project(1), ConversationParty::User, a)
.id; .id;
assert_eq!( assert_eq!(
first, second, first, second,
@ -226,8 +237,12 @@ mod tests {
// Même garantie pour une paire Agent↔Agent (dérivation XOR commutative). // Même garantie pour une paire Agent↔Agent (dérivation XOR commutative).
let x = agent(11); let x = agent(11);
let y = agent(13); let y = agent(13);
let first = InMemoryConversationRegistry::new().resolve(x, y).id; let first = InMemoryConversationRegistry::new()
let second = InMemoryConversationRegistry::new().resolve(y, x).id; .resolve(project(1), x, y)
.id;
let second = InMemoryConversationRegistry::new()
.resolve(project(1), y, x)
.id;
assert_eq!( assert_eq!(
first, second, first, second,
"id de paire Agent↔Agent stable au redémarrage, insensible à l'ordre" "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 agent_id = AgentId::from_uuid(uuid::Uuid::from_u128(42));
let party = ConversationParty::agent(agent_id); let party = ConversationParty::agent(agent_id);
let resolved = InMemoryConversationRegistry::new() let resolved = InMemoryConversationRegistry::new()
.resolve(ConversationParty::User, party) .resolve(project(1), ConversationParty::User, party)
.id; .id;
assert_eq!( assert_eq!(
resolved, resolved,
ConversationId::for_pair(ConversationParty::User, party), ConversationId::for_project_pair(project(1), ConversationParty::User, party),
"resolve == for_pair (alignement de clé)" "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] #[test]
@ -262,12 +272,12 @@ mod tests {
let x = agent(101); let x = agent(101);
let y = agent(202); let y = agent(202);
let reg = InMemoryConversationRegistry::new(); let reg = InMemoryConversationRegistry::new();
let id_xy = reg.resolve(x, y).id; let id_xy = reg.resolve(project(1), x, y).id;
let id_yx = reg.resolve(y, x).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, id_yx, "resolve(a,b) == resolve(b,a)");
assert_eq!( assert_eq!(
id_xy, id_xy,
ConversationId::for_pair(x, y), ConversationId::for_project_pair(project(1), x, y),
"resolve == for_pair (Agent↔Agent)" "resolve == for_pair (Agent↔Agent)"
); );
assert_eq!(reg.len(), 1, "une seule conversation pour la paire {{a,b}}"); 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() { fn distinct_pairs_yield_distinct_ids_across_kinds() {
// Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé). // Deux paires distinctes ⇒ deux ids distincts (pas de collision de clé).
let reg = InMemoryConversationRegistry::new(); let reg = InMemoryConversationRegistry::new();
let user_a = reg.resolve(ConversationParty::User, agent(1)).id; let user_a = reg
let user_b = reg.resolve(ConversationParty::User, agent(2)).id; .resolve(project(1), ConversationParty::User, agent(1))
let a_b = reg.resolve(agent(1), agent(2)).id; .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, user_b, "User↔A ≠ User↔B");
assert_ne!(user_a, a_b, "User↔A ≠ A↔B"); assert_ne!(user_a, a_b, "User↔A ≠ A↔B");
assert_ne!(user_b, a_b, "User↔B ≠ 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);
}
} }

View File

@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use domain::events::DomainEvent; use domain::events::DomainEvent;
use domain::ids::AgentId; use domain::ids::RuntimeAgentKey;
use domain::inbox::{ use domain::inbox::{
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus, AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxReceipt, InboxReceiptStatus,
InboxSource, DEFAULT_AGENT_INBOX_CAPACITY, InboxSource, DEFAULT_AGENT_INBOX_CAPACITY,
@ -44,11 +44,11 @@ use crate::mailbox::InMemoryMailbox;
/// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so /// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent. /// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
struct BusyTracker { struct BusyTracker {
busy: Mutex<HashMap<AgentId, AgentBusyState>>, busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
/// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de /// Per-agent **liveness** bookkeeping (lot 2) : dernier battement observé, seuil de
/// stagnation issu du profil, et état de vivacité courant pour n'émettre /// stagnation issu du profil, et état de vivacité courant pour n'émettre
/// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam). /// `AgentLivenessChanged` qu'**une fois par transition** (pas de spam).
liveness: Mutex<HashMap<AgentId, LivenessState>>, liveness: Mutex<HashMap<RuntimeAgentKey, LivenessState>>,
/// **Démarrage à froid** (fix race cold-launch) : ensemble des agents fraîchement /// **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* /// lancés à froid pour lesquels la livraison du **premier** tour doit être *gatée*
/// sur la readiness MCP. Un agent y est inscrit par /// 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` /// 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 /// 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é. /// CLI n'a pas encore chargé ses outils MCP). Vide ⇒ comportement chaud inchangé.
starting: Mutex<HashSet<AgentId>>, starting: Mutex<HashSet<RuntimeAgentKey>>,
/// **Tour différé** (fix race cold-launch) : payload de la `DelegationReady` retenue /// **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 /// 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. /// connexion du pont MCP (jamais avant). Absent ⇒ aucun tour en attente de gate.
deferred: Mutex<HashMap<AgentId, DeferredDelegation>>, deferred: Mutex<HashMap<RuntimeAgentKey, DeferredDelegation>>,
/// **Latch « déjà libéré »** (fix race cold-start, ordre inverse) : ensemble des /// **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 /// agents pour lesquels le signal de readiness (`release_cold_start`, pont MCP) est
/// arrivé **avant** que l'`enqueue` n'ait parqué son tour dans /// 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 /// 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 /// livre alors **immédiatement** au lieu de parquer. Consommé (retiré) à la livraison
/// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé. /// ⇒ exactement-une-fois, quel que soit l'ordre. Vide ⇒ comportement inchangé.
released: Mutex<HashSet<AgentId>>, released: Mutex<HashSet<RuntimeAgentKey>>,
events: Option<Arc<dyn EventBus>>, events: Option<Arc<dyn EventBus>>,
/// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par /// Sink de livraison headless (cf. [`HeadlessSink`]). Câblé par
/// [`MediatedInbox::with_pty`]/[`MediatedInbox::with_events`] quand un PTY est /// [`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 /// 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). /// PTY/handle disponible ⇒ repli sur l'événement, comportement historique).
type HeadlessSink = type HeadlessSink =
Arc<dyn Fn(AgentId, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>; Arc<dyn Fn(RuntimeAgentKey, DeferredDelegation) -> Option<DeferredDelegation> + Send + Sync>;
/// Délai (ms) entre l'écriture du texte de la tâche et celle de la séquence de /// 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). /// 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<AgentId>> { fn lock_starting(&self) -> std::sync::MutexGuard<'_, HashSet<RuntimeAgentKey>> {
self.starting self.starting
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn lock_deferred(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, DeferredDelegation>> { fn lock_deferred(
&self,
) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, DeferredDelegation>> {
self.deferred self.deferred
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> { fn lock_released(&self) -> std::sync::MutexGuard<'_, HashSet<RuntimeAgentKey>> {
self.released self.released
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .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 /// (`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 /// (aucun signal ne viendrait le libérer). Sans cet appel, l'`enqueue` publie la
/// `DelegationReady` immédiatement (chemin chaud, zéro régression). /// `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}"); application::diag!("[input-mediator] mark_starting cold-start gate armed agent={agent}");
self.lock_starting().insert(agent); self.lock_starting().insert(agent);
} }
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> { fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, AgentBusyState>> {
self.busy self.busy
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, LivenessState>> { fn lock_liveness(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, LivenessState>> {
self.liveness self.liveness
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
/// Publie un `AgentLivenessChanged` (si un bus est câblé). /// 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 { if let Some(events) = &self.events {
events.publish(DomainEvent::AgentLivenessChanged { events.publish(DomainEvent::AgentLivenessChanged {
agent_id: agent, agent_id: agent.agent_id,
liveness, liveness,
}); });
} }
@ -217,7 +219,7 @@ impl BusyTracker {
/// démarre (depuis l'enqueue) : (re)initialise `last_seen` à `now` et repart d'un /// 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 /// é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). /// la déclarera jamais `Stalled` (zéro régression pour un profil sans liveness).
fn arm_liveness(&self, agent: AgentId, stall_after_ms: Option<u32>, now_ms: u64) { fn arm_liveness(&self, agent: RuntimeAgentKey, stall_after_ms: Option<u32>, now_ms: u64) {
self.lock_liveness().insert( self.lock_liveness().insert(
agent, agent,
LivenessState { LivenessState {
@ -231,7 +233,7 @@ impl BusyTracker {
/// Rafraîchit le `last_seen` d'un agent (un **battement**) et, s'il était /// 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 /// `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). /// 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 recovered = {
let mut map = self.lock_liveness(); let mut map = self.lock_liveness();
let Some(state) = map.get_mut(&agent) else { 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 /// `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. /// pas. Un agent sans seuil (`None`) ou redevenu `Idle` n'est jamais déclaré stalled.
fn sweep_stalled(&self, now_ms: u64) { fn sweep_stalled(&self, now_ms: u64) {
let newly_stalled: Vec<AgentId> = { let newly_stalled: Vec<RuntimeAgentKey> = {
let mut map = self.lock_liveness(); let mut map = self.lock_liveness();
map.iter_mut() map.iter_mut()
.filter_map(|(agent, state)| { .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`, /// 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. /// 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 let was_stalled = self
.lock_liveness() .lock_liveness()
.remove(&agent) .remove(&agent)
@ -295,7 +297,7 @@ impl BusyTracker {
} }
} }
fn busy_state(&self, agent: AgentId) -> AgentBusyState { fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
self.lock() self.lock()
.get(&agent) .get(&agent)
.copied() .copied()
@ -304,7 +306,7 @@ impl BusyTracker {
/// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually /// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually
/// started (so the caller publishes `AgentBusyChanged{busy:true}` only once). /// 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 mut busy = self.lock();
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle); let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
if entry.is_busy() { if entry.is_busy() {
@ -320,7 +322,7 @@ impl BusyTracker {
/// Publie une [`DomainEvent::DelegationReady`] depuis un payload différé (si un bus /// 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 /// est câblé). Utilisée pour livrer le **premier** tour d'un agent froid au moment
/// où son prompt apparaît. /// 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 // 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 // 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 // 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, d.submit_delay_ms,
); );
events.publish(DomainEvent::DelegationReady { events.publish(DomainEvent::DelegationReady {
agent_id: agent, agent_id: agent.agent_id,
ticket: d.ticket, ticket: d.ticket,
text: d.text, text: d.text,
submit_sequence: d.submit_sequence, 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 /// 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 /// 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. /// `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); let active = self.lock().get(&agent).and_then(AgentBusyState::ticket);
application::diag!( application::diag!(
"[input-mediator] turn_ended agent={agent} no_reply_payload -> mark_idle + grace" "[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, /// 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` /// pas de fin de tour). Idempotent : c'est le seul drain du tour différé (le `remove`
/// ne rend `Some` qu'une fois). /// 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); let was_starting = self.lock_starting().remove(&agent);
if let Some(d) = self.lock_deferred().remove(&agent) { if let Some(d) = self.lock_deferred().remove(&agent) {
// Cas nominal : l'`enqueue` a déjà parqué le tour ⇒ on le draine. // 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 /// 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 /// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
/// no-op and emits nothing. /// no-op and emits nothing.
fn mark_idle(&self, agent: AgentId) { fn mark_idle(&self, agent: RuntimeAgentKey) {
let was_busy = { let was_busy = {
let mut busy = self.lock(); let mut busy = self.lock();
busy.insert(agent, AgentBusyState::Idle) busy.insert(agent, AgentBusyState::Idle)
@ -431,7 +433,7 @@ impl BusyTracker {
application::diag!("[input-mediator] mark_idle turn ended agent={agent}"); application::diag!("[input-mediator] mark_idle turn ended agent={agent}");
if let Some(events) = &self.events { if let Some(events) = &self.events {
events.publish(DomainEvent::AgentBusyChanged { events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent, agent_id: agent.agent_id,
busy: false, busy: false,
}); });
} }
@ -485,21 +487,21 @@ pub struct MediatedInbox {
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`. /// 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 /// `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. /// to resolve an agent's PTY handle when it must write the turn itself.
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>, handles: Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
/// Agents qui ont une **cellule terminal frontend montée** (write-portal actif), /// Agents qui ont une **cellule terminal frontend montée** (write-portal actif),
/// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure, /// tenu à jour par [`InputMediator::set_front_attached`]. Quand un agent y figure,
/// la livraison passe par l'événement `DelegationReady` (le front écrit) ; sinon /// 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 /// (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. /// dans le PTY. `Arc` car le sink headless du tracker le consulte.
front_owned: Arc<Mutex<HashSet<AgentId>>>, front_owned: Arc<Mutex<HashSet<RuntimeAgentKey>>>,
/// Per-agent submit config (target profile's `submit_sequence`/`submit_delay_ms`), /// 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 /// stashed at bind time (§20.3) and echoed on the `DelegationReady` event when a
/// turn starts. Absent ⇒ both `None` (the front applies its defaults). /// turn starts. Absent ⇒ both `None` (the front applies its defaults).
submit: Mutex<HashMap<AgentId, SubmitConfig>>, submit: Mutex<HashMap<RuntimeAgentKey, SubmitConfig>>,
/// Per-agent stall threshold (`LivenessStrategy::stall_after_ms`, lot 2), stashed by /// 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 /// `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). /// that starts a turn. Absent ⇒ `None` (no stall detection — legacy behaviour).
stall: Mutex<HashMap<AgentId, Option<u32>>>, stall: Mutex<HashMap<RuntimeAgentKey, Option<u32>>>,
/// Rich inbox payloads keyed by the mailbox ticket id. The mailbox remains the /// Rich inbox payloads keyed by the mailbox ticket id. The mailbox remains the
/// only FIFO; this map is metadata only. /// only FIFO; this map is metadata only.
inbox_items: Mutex<HashMap<TicketId, InboxItem>>, inbox_items: Mutex<HashMap<TicketId, InboxItem>>,
@ -545,8 +547,8 @@ impl MediatedInbox {
fn build_tracker( fn build_tracker(
events: Option<Arc<dyn EventBus>>, events: Option<Arc<dyn EventBus>>,
pty: Option<&Arc<dyn PtyPort>>, pty: Option<&Arc<dyn PtyPort>>,
handles: &Arc<Mutex<HashMap<AgentId, PtyHandle>>>, handles: &Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
front_owned: &Arc<Mutex<HashSet<AgentId>>>, front_owned: &Arc<Mutex<HashSet<RuntimeAgentKey>>>,
mailbox: &Arc<InMemoryMailbox>, mailbox: &Arc<InMemoryMailbox>,
grace: Duration, grace: Duration,
) -> Arc<BusyTracker> { ) -> Arc<BusyTracker> {
@ -572,10 +574,10 @@ impl MediatedInbox {
/// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant. /// `Some(d)` ⇒ le tracker publie l'événement `DelegationReady` comme avant.
fn make_headless_sink( fn make_headless_sink(
pty: Arc<dyn PtyPort>, pty: Arc<dyn PtyPort>,
handles: Arc<Mutex<HashMap<AgentId, PtyHandle>>>, handles: Arc<Mutex<HashMap<RuntimeAgentKey, PtyHandle>>>,
front_owned: Arc<Mutex<HashSet<AgentId>>>, front_owned: Arc<Mutex<HashSet<RuntimeAgentKey>>>,
) -> HeadlessSink { ) -> 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 // 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. // composer avec une saisie humaine en cours). On rend la main.
if front_owned if front_owned
@ -749,19 +751,19 @@ impl MediatedInbox {
self self
} }
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> { fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, PtyHandle>> {
self.handles self.handles
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, SubmitConfig>> { fn submit(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, SubmitConfig>> {
self.submit self.submit
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
} }
fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, Option<u32>>> { fn stall(&self) -> std::sync::MutexGuard<'_, HashMap<RuntimeAgentKey, Option<u32>>> {
self.stall self.stall
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
@ -843,7 +845,7 @@ fn describe_submit_sequence(value: Option<&str>) -> String {
} }
impl InputMediator for MediatedInbox { 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; let ticket_id = ticket.id;
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already // 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 // 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 started_turn {
if let Some(events) = &self.tracker.events { if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged { events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent, agent_id: agent.agent_id,
busy: true, busy: true,
}); });
let submit = self.submit().get(&agent).cloned().unwrap_or_default(); let submit = self.submit().get(&agent).cloned().unwrap_or_default();
@ -921,7 +923,7 @@ impl InputMediator for MediatedInbox {
self.mailbox.enqueue(agent, ticket) 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; let ticket_id = ticket.id;
// Headless/system turns share the same FIFO and busy/liveness accounting as // Headless/system turns share the same FIFO and busy/liveness accounting as
// terminal-delivered turns, but their prompt is sent through AgentSession::send. // 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); self.tracker.arm_liveness(agent, stall_after_ms, now_ms);
if let Some(events) = &self.tracker.events { if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged { events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent, agent_id: agent.agent_id,
busy: true, busy: true,
}); });
} }
@ -948,7 +950,7 @@ impl InputMediator for MediatedInbox {
self.mailbox.enqueue(agent, ticket) self.mailbox.enqueue(agent, ticket)
} }
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) { fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
eprintln!( eprintln!(
"[input-mediator] bind handle agent={agent} handle={}", "[input-mediator] bind handle agent={agent} handle={}",
handle.session_id handle.session_id
@ -956,7 +958,12 @@ impl InputMediator for MediatedInbox {
self.handles().insert(agent, handle); 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 // 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). // 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 // 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); 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() 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 // 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 // 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 // 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); 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); 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 let mut front = self
.front_owned .front_owned
.lock() .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 // Interrompre: signals the running turn to stop. It is NOT an enqueue and
// correlates **no** ticket (we never pop/resolve a pending caller — preempt // correlates **no** ticket (we never pop/resolve a pending caller — preempt
// must never silently answer one). The only effect is a best-effort interrupt // 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} // 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 // once. Also clears the liveness entry (fin de tour) — émet la reprise si l'agent
// était `Stalled`. // était `Stalled`.
self.tracker.mark_idle(agent); 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 // 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 // sans `idea_reply` ⇒ `mark_idle` + fenêtre de grâce pour réveiller l'appelant
// parqué (cf. [`BusyTracker::turn_ended`]). Backstop no-reply. // parqué (cf. [`BusyTracker::turn_ended`]). Backstop no-reply.
self.tracker.turn_ended(agent); 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 // Un battement (delta / activité / heartbeat) rafraîchit `last_seen` et ramène
// l'agent à `Alive` s'il était `Stalled` (lot 2). // l'agent à `Alive` s'il était `Stalled` (lot 2).
self.tracker.touch(agent, self.clock.now_ms()); self.tracker.touch(agent, self.clock.now_ms());
} }
fn set_stall_threshold(&self, agent: AgentId, stall_after_ms: Option<u32>) { fn set_stall_threshold(&self, agent: RuntimeAgentKey, stall_after_ms: Option<u32>) {
// Stashé par l'orchestrateur depuis le profil de la cible AVANT l'enqueue qui // 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). // démarre le tour ; consommé par `arm_liveness` au start_turn (lot 2).
self.stall().insert(agent, stall_after_ms); 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) self.tracker.busy_state(agent)
} }
} }
impl AgentInbox for MediatedInbox { impl AgentInbox for MediatedInbox {
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError> { fn enqueue_message(
if item.agent_id != agent { &self,
agent: RuntimeAgentKey,
item: InboxItem,
) -> Result<InboxReceipt, InboxError> {
if item.agent_id != agent.agent_id {
return Err(InboxError::AgentMismatch { return Err(InboxError::AgentMismatch {
agent_id: agent, agent_id: agent.agent_id,
item_agent_id: item.agent_id, item_agent_id: item.agent_id,
}); });
} }
@ -1063,13 +1074,14 @@ impl AgentInbox for MediatedInbox {
if item.is_lossless_system() { if item.is_lossless_system() {
return Ok(InboxReceipt { return Ok(InboxReceipt {
item_id: item.id, item_id: item.id,
agent_id: agent, agent_id: agent.agent_id,
runtime_key: agent,
depth: depth_before, depth: depth_before,
status: InboxReceiptStatus::Deferred, status: InboxReceiptStatus::Deferred,
}); });
} }
return Err(InboxError::InboxFull { return Err(InboxError::InboxFull {
agent_id: agent, agent_id: agent.agent_id,
capacity: self.inbox_capacity, capacity: self.inbox_capacity,
}); });
} }
@ -1081,32 +1093,33 @@ impl AgentInbox for MediatedInbox {
let depth = self.mailbox.pending(&agent); let depth = self.mailbox.pending(&agent);
if let Some(events) = &self.tracker.events { if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxQueued { events.publish(DomainEvent::AgentInboxQueued {
agent_id: agent, agent_id: agent.agent_id,
depth, depth,
}); });
} }
Ok(InboxReceipt { Ok(InboxReceipt {
item_id, item_id,
agent_id: agent, agent_id: agent.agent_id,
runtime_key: agent,
depth, depth,
status: InboxReceiptStatus::Queued, status: InboxReceiptStatus::Queued,
}) })
} }
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem> { fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem> {
let head = self.mailbox.head_ticket(&agent)?; let head = self.mailbox.head_ticket(&agent)?;
let item = self.inbox_items().remove(&head)?; let item = self.inbox_items().remove(&head)?;
self.mailbox.cancel_head(agent, head); self.mailbox.cancel_head(agent, head);
if let Some(events) = &self.tracker.events { if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentInboxDrained { events.publish(DomainEvent::AgentInboxDrained {
agent_id: agent, agent_id: agent.agent_id,
depth: self.snapshot(agent).depth, depth: self.snapshot(agent).depth,
}); });
} }
Some(item) Some(item)
} }
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot { fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot {
let items_by_id = self.inbox_items(); let items_by_id = self.inbox_items();
let items: Vec<InboxItem> = self let items: Vec<InboxItem> = self
.mailbox .mailbox
@ -1115,7 +1128,8 @@ impl AgentInbox for MediatedInbox {
.filter_map(|ticket| items_by_id.get(&ticket.id).cloned()) .filter_map(|ticket| items_by_id.get(&ticket.id).cloned())
.collect(); .collect();
AgentInboxSnapshot { AgentInboxSnapshot {
agent_id: agent, agent_id: agent.agent_id,
runtime_key: agent,
depth: self.mailbox.pending(&agent), depth: self.mailbox.pending(&agent),
items, items,
} }
@ -1152,6 +1166,7 @@ mod tests {
use super::*; use super::*;
use domain::conversation::ConversationId; use domain::conversation::ConversationId;
use domain::mailbox::TicketId; use domain::mailbox::TicketId;
use domain::AgentId;
/// Deterministic clock for assertions on `since_ms`. /// Deterministic clock for assertions on `since_ms`.
struct FixedClock(u64); struct FixedClock(u64);
@ -1165,6 +1180,13 @@ mod tests {
AgentId::from_uuid(uuid::Uuid::from_u128(n)) 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 { fn ticket(n: u128, task: &str) -> Ticket {
Ticket::from_human( Ticket::from_human(
TicketId::from_uuid(uuid::Uuid::from_u128(n)), TicketId::from_uuid(uuid::Uuid::from_u128(n)),
@ -1240,27 +1262,33 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// First enqueue starts a turn ⇒ exactly one Busy(true) event. // First enqueue starts a turn ⇒ exactly one Busy(true) event.
inbox.enqueue(a, ticket(10, "first")); 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. // Second enqueue while Busy queues behind ⇒ NO new busy event.
inbox.enqueue(a, ticket(11, "second")); inbox.enqueue(a, ticket(11, "second"));
assert_eq!( assert_eq!(
bus.busy_events(), bus.busy_events(),
vec![(a, true)], vec![(a.agent_id, true)],
"no re-announce while busy" "no re-announce while busy"
); );
// mark_idle on a busy agent ⇒ exactly one Idle(false) event. // mark_idle on a busy agent ⇒ exactly one Idle(false) event.
inbox.mark_idle(a); 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. // mark_idle on an already-idle agent ⇒ no spurious event.
inbox.mark_idle(a); 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] #[test]
@ -1268,11 +1296,11 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.enqueue(a, ticket(10, "t")); inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a); inbox.preempt(a);
// Only the enqueue's Busy(true); preempt does not toggle busy state. // 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 bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket. // Idle→Busy ⇒ exactly one DelegationReady carrying the task text + ticket.
inbox.enqueue(a, ticket(10, "do the thing")); inbox.enqueue(a, ticket(10, "do the thing"));
@ -1325,14 +1353,14 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>, Arc::clone(&pty) as Arc<dyn PtyPort>,
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.set_front_attached(a, true); inbox.set_front_attached(a, true);
inbox.enqueue_silent(a, ticket(10, "headless turn")); inbox.enqueue_silent(a, ticket(10, "headless turn"));
assert!(inbox.busy_state(a).is_busy()); 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!( assert!(
bus.delegation_ready().is_empty(), bus.delegation_ready().is_empty(),
"headless bookkeeping must not leak a prompt to the terminal" "headless bookkeeping must not leak a prompt to the terminal"
@ -1349,11 +1377,11 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
let task_id = domain::TaskId::from_uuid(uuid::Uuid::from_u128(42)); let task_id = domain::TaskId::from_uuid(uuid::Uuid::from_u128(42));
let item = domain::InboxItem { let item = domain::InboxItem {
id: TicketId::from_uuid(uuid::Uuid::from_u128(10)), id: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
agent_id: a, agent_id: a.agent_id,
source: InboxSource::BackgroundTask { task_id }, source: InboxSource::BackgroundTask { task_id },
kind: domain::InboxItemKind::BackgroundCompletion, kind: domain::InboxItemKind::BackgroundCompletion,
body: "Background task completed.".to_owned(), body: "Background task completed.".to_owned(),
@ -1384,7 +1412,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>, Arc::clone(&pty) as Arc<dyn PtyPort>,
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
let h = handle(1); let h = handle(1);
// Bind the target's submit config (resolved from its profile by the service). // 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 bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.enqueue(a, ticket(10, "task")); inbox.enqueue(a, ticket(10, "task"));
let ready = bus.delegation_ready(); let ready = bus.delegation_ready();
assert_eq!(ready.len(), 1); assert_eq!(ready.len(), 1);
@ -1428,7 +1456,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>, Arc::clone(&pty) as Arc<dyn PtyPort>,
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
inbox.set_front_attached(a, true); inbox.set_front_attached(a, true);
@ -1460,7 +1488,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>, Arc::clone(&pty) as Arc<dyn PtyPort>,
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// No set_front_attached ⇒ headless. // No set_front_attached ⇒ headless.
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
@ -1495,7 +1523,7 @@ mod tests {
Arc::clone(&pty) as Arc<dyn PtyPort>, Arc::clone(&pty) as Arc<dyn PtyPort>,
) )
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
let task = format!("début {} fin", "x".repeat(1200)); let task = format!("début {} fin", "x".repeat(1200));
inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default()); inbox.bind_handle_with_submit(a, handle(1), SubmitConfig::default());
@ -1531,7 +1559,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() { async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5); let inbox = inbox_at(5);
let a = agent(1); let a = key(1);
let pending = inbox.enqueue(a, ticket(10, "do X")); let pending = inbox.enqueue(a, ticket(10, "do X"));
// Resolve through the shared mailbox (the orchestrator's path). // Resolve through the shared mailbox (the orchestrator's path).
inbox.mailbox().resolve(a, "done".to_owned()).unwrap(); inbox.mailbox().resolve(a, "done".to_owned()).unwrap();
@ -1544,7 +1572,7 @@ mod tests {
#[test] #[test]
fn first_enqueue_marks_busy_with_ticket_and_stamp() { fn first_enqueue_marks_busy_with_ticket_and_stamp() {
let inbox = inbox_at(1234); let inbox = inbox_at(1234);
let a = agent(1); let a = key(1);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
inbox.enqueue(a, ticket(10, "t")); inbox.enqueue(a, ticket(10, "t"));
assert_eq!( assert_eq!(
@ -1559,7 +1587,7 @@ mod tests {
#[test] #[test]
fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() { fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() {
let inbox = inbox_at(1); let inbox = inbox_at(1);
let a = agent(1); let a = key(1);
inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox. // Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
@ -1573,7 +1601,7 @@ mod tests {
#[test] #[test]
fn mark_idle_returns_to_idle_so_next_turn_can_start() { fn mark_idle_returns_to_idle_so_next_turn_can_start() {
let inbox = inbox_at(1); let inbox = inbox_at(1);
let a = agent(1); let a = key(1);
inbox.enqueue(a, ticket(10, "t")); inbox.enqueue(a, ticket(10, "t"));
assert!(inbox.busy_state(a).is_busy()); assert!(inbox.busy_state(a).is_busy());
inbox.mark_idle(a); inbox.mark_idle(a);
@ -1589,7 +1617,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() { async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() {
let inbox = inbox_at(1); let inbox = inbox_at(1);
let a = agent(1); let a = key(1);
let pending = inbox.enqueue(a, ticket(10, "t")); let pending = inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a); inbox.preempt(a);
// preempt did not pop/resolve the ticket: still pending in the mailbox. // preempt did not pop/resolve the ticket: still pending in the mailbox.
@ -1605,7 +1633,7 @@ mod tests {
#[test] #[test]
fn two_enqueues_same_agent_serialise_in_one_fifo() { fn two_enqueues_same_agent_serialise_in_one_fifo() {
let inbox = inbox_at(1); let inbox = inbox_at(1);
let a = agent(1); let a = key(1);
inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2); assert_eq!(inbox.mailbox().pending(&a), 2);
@ -1619,8 +1647,8 @@ mod tests {
#[test] #[test]
fn different_agents_are_independent_not_blocking() { fn different_agents_are_independent_not_blocking() {
let inbox = inbox_at(1); let inbox = inbox_at(1);
let a = agent(1); let a = key(1);
let b = agent(2); let b = key(2);
inbox.enqueue(a, ticket(10, "a")); inbox.enqueue(a, ticket(10, "a"));
inbox.enqueue(b, ticket(20, "b")); inbox.enqueue(b, ticket(20, "b"));
assert!(inbox.busy_state(a).is_busy()); assert!(inbox.busy_state(a).is_busy());
@ -1721,7 +1749,7 @@ mod tests {
/// blocage jusqu'au timeout long (le bug corrigé). /// blocage jusqu'au timeout long (le bug corrigé).
#[tokio::test] #[tokio::test]
async fn turn_ended_without_reply_wakes_caller_after_grace() { 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 inbox = inbox_grace(Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task")); 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. /// et la complétion de grâce (tête déjà retirée) est un no-op.
#[tokio::test] #[tokio::test]
async fn reply_before_turn_ended_wins() { async fn reply_before_turn_ended_wins() {
let a = agent(1); let a = key(1);
let inbox = inbox_grace(Duration::from_millis(40)); let inbox = inbox_grace(Duration::from_millis(40));
let pending = inbox.enqueue(a, ticket(10, "task")); 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. /// avec une grâce longue, on résout via la mailbox avant son expiration.
#[tokio::test] #[tokio::test]
async fn reply_within_grace_after_turn_ended_wins() { 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. // Long grace ⇒ the reply lands well within it.
let inbox = inbox_grace(Duration::from_secs(30)); 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. /// été retirée par la complétion) — typé, idempotent, jamais un panic.
#[tokio::test] #[tokio::test]
async fn reply_after_grace_is_unmatched() { async fn reply_after_grace_is_unmatched() {
let a = agent(1); let a = key(1);
let inbox = inbox_grace(Duration::from_millis(30)); let inbox = inbox_grace(Duration::from_millis(30));
let pending = inbox.enqueue(a, ticket(10, "task")); 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é. /// retirer la tête — `complete_without_reply` skip quand le receiver est fermé.
#[test] #[test]
fn fire_and_forget_head_is_preserved_through_grace() { 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)); let inbox = inbox_grace(Duration::from_millis(20));
// Human submit: the reply handle is dropped immediately (not awaited). // Human submit: the reply handle is dropped immediately (not awaited).
@ -1841,7 +1869,7 @@ mod tests {
/// idempotent, aucune grâce armée, aucun panic. /// idempotent, aucune grâce armée, aucun panic.
#[test] #[test]
fn turn_ended_on_idle_agent_is_noop() { fn turn_ended_on_idle_agent_is_noop() {
let a = agent(1); let a = key(1);
let inbox = inbox_grace(Duration::from_millis(20)); let inbox = inbox_grace(Duration::from_millis(20));
inbox.turn_ended(a); // jamais de tour démarré. inbox.turn_ended(a); // jamais de tour démarré.
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle); assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
@ -1859,7 +1887,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur). // Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a); inbox.mark_starting(a);
@ -1893,7 +1921,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur). // Démarrage à froid : gate armé AVANT l'enqueue (ordre de l'orchestrateur).
inbox.mark_starting(a); inbox.mark_starting(a);
@ -1927,7 +1955,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting). // Démarre un tour normal (Busy), SANS gate cold-launch (pas de mark_starting).
inbox.enqueue(a, ticket(10, "task")); inbox.enqueue(a, ticket(10, "task"));
@ -1952,7 +1980,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.mark_starting(a); inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "cold task")); inbox.enqueue(a, ticket(10, "cold task"));
@ -1973,7 +2001,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Pas de mark_starting ⇒ agent chaud. // Pas de mark_starting ⇒ agent chaud.
inbox.enqueue(a, ticket(10, "warm task")); inbox.enqueue(a, ticket(10, "warm task"));
@ -1994,7 +2022,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
// Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting. // Cold launch SANS pont MCP ⇒ l'orchestrateur N'APPELLE PAS mark_starting.
inbox.enqueue(a, ticket(10, "task")); inbox.enqueue(a, ticket(10, "task"));
@ -2017,7 +2045,7 @@ mod tests {
let bus = Arc::new(RecordingBus::default()); let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1))) let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>); .with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1); let a = key(1);
inbox.mark_starting(a); inbox.mark_starting(a);
inbox.enqueue(a, ticket(10, "first")); inbox.enqueue(a, ticket(10, "first"));
@ -2091,7 +2119,7 @@ mod tests {
fn no_heartbeat_past_threshold_marks_stalled() { fn no_heartbeat_past_threshold_marks_stalled() {
let clock = MutClock::new(1_000); let clock = MutClock::new(1_000);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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. // Profil : seuil de stagnation à 30_000 ms. Armé avant le tour.
inbox.set_stall_threshold(a, Some(30_000)); inbox.set_stall_threshold(a, Some(30_000));
@ -2109,14 +2137,17 @@ mod tests {
// Au-delà du seuil : exactement une transition Stalled. // Au-delà du seuil : exactement une transition Stalled.
clock.set(1_000 + 30_001); clock.set(1_000 + 30_001);
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)]
);
// Idempotent : un second sweep ne ré-émet pas. // Idempotent : un second sweep ne ré-émet pas.
clock.set(1_000 + 60_000); clock.set(1_000 + 60_000);
inbox.sweep_stalled(); inbox.sweep_stalled();
assert_eq!( assert_eq!(
bus.liveness_events(), bus.liveness_events(),
vec![(a, AgentLiveness::Stalled)], vec![(a.agent_id, AgentLiveness::Stalled)],
"déjà stalled ⇒ pas de spam" "déjà stalled ⇒ pas de spam"
); );
} }
@ -2126,7 +2157,7 @@ mod tests {
fn heartbeat_within_window_resets_last_seen() { fn heartbeat_within_window_resets_last_seen() {
let clock = MutClock::new(1_000); let clock = MutClock::new(1_000);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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.set_stall_threshold(a, Some(30_000));
inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000 inbox.enqueue(a, ticket(10, "task")); // last_seen=1_000
@ -2149,7 +2180,7 @@ mod tests {
fn liveness_event_once_per_transition() { fn liveness_event_once_per_transition() {
let clock = MutClock::new(0); let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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.set_stall_threshold(a, Some(10_000));
inbox.enqueue(a, ticket(10, "t")); // last_seen=0 inbox.enqueue(a, ticket(10, "t")); // last_seen=0
@ -2159,7 +2190,10 @@ mod tests {
// Sweeps répétés : pas de ré-émission. // Sweeps répétés : pas de ré-émission.
inbox.sweep_stalled(); inbox.sweep_stalled();
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. // Battement tardif ⇒ une seule reprise Alive.
clock.set(20_000); clock.set(20_000);
@ -2167,7 +2201,10 @@ mod tests {
inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien. inbox.mark_alive(a); // second battement : déjà Alive ⇒ rien.
assert_eq!( assert_eq!(
bus.liveness_events(), 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). // Re-stall possible après reprise (nouvelle transition).
@ -2176,9 +2213,9 @@ mod tests {
assert_eq!( assert_eq!(
bus.liveness_events(), bus.liveness_events(),
vec![ vec![
(a, AgentLiveness::Stalled), (a.agent_id, AgentLiveness::Stalled),
(a, AgentLiveness::Alive), (a.agent_id, AgentLiveness::Alive),
(a, AgentLiveness::Stalled), (a.agent_id, AgentLiveness::Stalled),
] ]
); );
} }
@ -2188,17 +2225,23 @@ mod tests {
fn mark_idle_on_stalled_emits_recovery_and_clears() { fn mark_idle_on_stalled_emits_recovery_and_clears() {
let clock = MutClock::new(0); let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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.set_stall_threshold(a, Some(5_000));
inbox.enqueue(a, ticket(10, "t")); inbox.enqueue(a, ticket(10, "t"));
clock.set(5_001); clock.set(5_001);
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)]
);
inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée. inbox.mark_idle(a); // fin de tour ⇒ reprise Alive + entrée retirée.
assert_eq!( assert_eq!(
bus.liveness_events(), 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). // 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(); inbox.sweep_stalled();
assert_eq!( assert_eq!(
bus.liveness_events(), 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() { fn agent_without_threshold_is_never_stalled() {
let clock = MutClock::new(0); let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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. // Pas de set_stall_threshold (ou None) : armé sans seuil au start_turn.
inbox.enqueue(a, ticket(10, "t")); inbox.enqueue(a, ticket(10, "t"));
clock.set(10_000_000); // très loin dans le futur. 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() { fn mark_alive_on_unarmed_agent_is_noop() {
let clock = MutClock::new(0); let clock = MutClock::new(0);
let (inbox, bus) = inbox_with_clock(Arc::clone(&clock)); 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.mark_alive(a); // jamais de tour démarré.
inbox.sweep_stalled(); inbox.sweep_stalled();
assert_eq!(bus.liveness_events(), vec![]); assert_eq!(bus.liveness_events(), vec![]);

View File

@ -24,7 +24,7 @@ use std::sync::Mutex;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use domain::ids::AgentId; use domain::ids::RuntimeAgentKey;
use domain::mailbox::{ use domain::mailbox::{
AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket, AgentMailbox, AgentQueueSnapshot, MailboxError, PendingReply, QueuedTicketSnapshot, Ticket,
TicketId, TurnResolution, TicketId, TurnResolution,
@ -44,7 +44,7 @@ struct Slot {
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]). /// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)] #[derive(Default)]
pub struct InMemoryMailbox { pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>, queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<Slot>>>,
} }
impl InMemoryMailbox { impl InMemoryMailbox {
@ -58,7 +58,7 @@ impl InMemoryMailbox {
/// Number of tickets currently queued for `agent` (test/inspection helper). /// Number of tickets currently queued for `agent` (test/inspection helper).
#[must_use] #[must_use]
pub fn pending(&self, agent: &AgentId) -> usize { pub fn pending(&self, agent: &RuntimeAgentKey) -> usize {
self.queues self.queues
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .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 /// The id of the ticket currently at the head of `agent`'s queue, if any
/// (test/inspection helper). /// (test/inspection helper).
#[must_use] #[must_use]
pub fn head_ticket(&self, agent: &AgentId) -> Option<TicketId> { pub fn head_ticket(&self, agent: &RuntimeAgentKey) -> Option<TicketId> {
self.queues self.queues
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
@ -80,7 +80,7 @@ impl InMemoryMailbox {
} }
impl AgentMailbox for 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::<TurnResolution>(); let (tx, rx) = oneshot::channel::<TurnResolution>();
let ticket_id = ticket.id; let ticket_id = ticket.id;
let (depth_before, depth_after) = { 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 // 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 // is non-blocking). If the receiver already went away (caller timed out), the
// ticket is still correctly retired from the head — the queue advances. // ticket is still correctly retired from the head — the queue advances.
@ -121,7 +121,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!( application::diag!(
"[mailbox] resolve no-pending agent={agent} (réponse orpheline ?)" "[mailbox] resolve no-pending agent={agent} (réponse orpheline ?)"
); );
MailboxError::NoPendingRequest(agent) MailboxError::NoPendingRequest(agent.agent_id)
})?; })?;
let before = queue.len(); let before = queue.len();
let slot = queue.pop_front().expect("non-empty queue has a head"); let slot = queue.pop_front().expect("non-empty queue has a head");
@ -140,7 +140,7 @@ impl AgentMailbox for InMemoryMailbox {
fn resolve_ticket( fn resolve_ticket(
&self, &self,
agent: AgentId, agent: RuntimeAgentKey,
ticket_id: TicketId, ticket_id: TicketId,
result: String, result: String,
) -> Result<(), MailboxError> { ) -> Result<(), MailboxError> {
@ -159,7 +159,7 @@ impl AgentMailbox for InMemoryMailbox {
application::diag!( application::diag!(
"[mailbox] resolve_ticket no-queue agent={agent} ticket={ticket_id}" "[mailbox] resolve_ticket no-queue agent={agent} ticket={ticket_id}"
); );
MailboxError::NoPendingRequest(agent) MailboxError::NoPendingRequest(agent.agent_id)
})?; })?;
let before = queue.len(); let before = queue.len();
let pos = queue let pos = queue
@ -170,7 +170,7 @@ impl AgentMailbox for InMemoryMailbox {
"[mailbox] resolve_ticket no-match agent={agent} ticket={ticket_id} \ "[mailbox] resolve_ticket no-match agent={agent} ticket={ticket_id} \
queue_depth={before}" queue_depth={before}"
); );
MailboxError::NoPendingRequest(agent) MailboxError::NoPendingRequest(agent.agent_id)
})?; })?;
let slot = queue.remove(pos).expect("position just found is in range"); let slot = queue.remove(pos).expect("position just found is in range");
(slot, before, queue.len()) (slot, before, queue.len())
@ -183,7 +183,7 @@ impl AgentMailbox for InMemoryMailbox {
Ok(()) 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 (depth_before, depth_after, retired) = {
let mut queues = self let mut queues = self
.queues .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). // Head-only, idempotent, and fire-and-forget-safe (see the port contract).
// `outcome` records what happened for the diag beacon: "sent" (head retired + // `outcome` records what happened for the diag beacon: "sent" (head retired +
// caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already // caller woken with ReturnedToPromptNoReply), "skip-closed" (receiver already
@ -273,7 +273,7 @@ impl AgentMailbox for InMemoryMailbox {
} }
impl AgentQueueSnapshot for InMemoryMailbox { impl AgentQueueSnapshot for InMemoryMailbox {
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> { fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot> {
// Pure read: clone each ticket's *data* (never the `oneshot::Sender`) under // 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 lock, recomputing the FIFO position from the current order (0 = head).
// The queue is observed, not mutated. // The queue is observed, not mutated.
@ -304,11 +304,19 @@ impl AgentQueueSnapshot for InMemoryMailbox {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::AgentId;
fn agent(n: u128) -> AgentId { fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n)) 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 { fn ticket(n: u128, task: &str) -> Ticket {
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task) Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
} }
@ -316,7 +324,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn enqueue_then_resolve_wakes_the_pending_reply() { async fn enqueue_then_resolve_wakes_the_pending_reply() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let pending = mb.enqueue(a, ticket(10, "do X")); let pending = mb.enqueue(a, ticket(10, "do X"));
mb.resolve(a, "done X".to_owned()).expect("resolve ok"); mb.resolve(a, "done X".to_owned()).expect("resolve ok");
@ -329,7 +337,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn two_asks_same_target_resolve_fifo_head_first() { async fn two_asks_same_target_resolve_fifo_head_first() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "first")); let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second")); let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2); assert_eq!(mb.pending(&a), 2);
@ -353,8 +361,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn different_targets_do_not_block_each_other() { async fn different_targets_do_not_block_each_other() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let b = agent(2); let b = key(2);
let pa = mb.enqueue(a, ticket(10, "task a")); let pa = mb.enqueue(a, ticket(10, "task a"));
let _pb = mb.enqueue(b, ticket(20, "task b")); let _pb = mb.enqueue(b, ticket(20, "task b"));
@ -368,17 +376,17 @@ mod tests {
#[test] #[test]
fn resolve_without_pending_is_a_typed_error() { fn resolve_without_pending_is_a_typed_error() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
assert_eq!( assert_eq!(
mb.resolve(a, "orphan".to_owned()), mb.resolve(a, "orphan".to_owned()),
Err(MailboxError::NoPendingRequest(a)) Err(MailboxError::NoPendingRequest(a.agent_id))
); );
} }
#[tokio::test] #[tokio::test]
async fn cancel_head_retires_the_head_and_advances_the_queue() { async fn cancel_head_retires_the_head_and_advances_the_queue() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "stuck")); let p1 = mb.enqueue(a, ticket(10, "stuck"));
let p2 = mb.enqueue(a, ticket(11, "next")); let p2 = mb.enqueue(a, ticket(11, "next"));
@ -401,7 +409,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() { async fn complete_without_reply_wakes_head_caller_with_returned_to_prompt() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let pending = mb.enqueue(a, ticket(10, "awaited")); let pending = mb.enqueue(a, ticket(10, "awaited"));
// The target returned to its prompt without idea_reply ⇒ the awaiting caller // The target returned to its prompt without idea_reply ⇒ the awaiting caller
@ -417,7 +425,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn complete_without_reply_is_noop_when_receiver_already_gone() { async fn complete_without_reply_is_noop_when_receiver_already_gone() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
// Human fire-and-forget: the PendingReply is dropped immediately (not awaited). // Human fire-and-forget: the PendingReply is dropped immediately (not awaited).
drop(mb.enqueue(a, ticket(10, "human submit"))); drop(mb.enqueue(a, ticket(10, "human submit")));
@ -434,7 +442,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn complete_without_reply_is_noop_when_not_head_and_idempotent() { async fn complete_without_reply_is_noop_when_not_head_and_idempotent() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let p1 = mb.enqueue(a, ticket(10, "head")); let p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "behind")); let _p2 = mb.enqueue(a, ticket(11, "behind"));
@ -455,7 +463,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn reply_before_completion_wins_then_completion_is_noop() { async fn reply_before_completion_wins_then_completion_is_noop() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let pending = mb.enqueue(a, ticket(10, "raced")); let pending = mb.enqueue(a, ticket(10, "raced"));
mb.resolve(a, "real answer".to_owned()).unwrap(); mb.resolve(a, "real answer".to_owned()).unwrap();
@ -472,7 +480,7 @@ mod tests {
#[test] #[test]
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() { fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "head")); let _p1 = mb.enqueue(a, ticket(10, "head"));
// Try to cancel a ticket that is NOT the head ⇒ nothing retired. // Try to cancel a ticket that is NOT the head ⇒ nothing retired.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99))); mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
@ -490,13 +498,13 @@ mod tests {
#[test] #[test]
fn snapshot_of_empty_queue_is_empty() { fn snapshot_of_empty_queue_is_empty() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
assert!(mb.queue_for(agent(1)).is_empty()); assert!(mb.queue_for(key(1)).is_empty());
} }
#[test] #[test]
fn snapshot_preserves_fifo_order_and_positions() { fn snapshot_preserves_fifo_order_and_positions() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first")); let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second")); let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -516,7 +524,7 @@ mod tests {
use domain::input::InputSource; use domain::input::InputSource;
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let from = agent(2); let from = agent(2);
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42)); let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(42));
let _p = mb.enqueue( let _p = mb.enqueue(
@ -535,7 +543,7 @@ mod tests {
#[test] #[test]
fn snapshot_is_read_only() { fn snapshot_is_read_only() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first")); let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second")); let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -550,7 +558,7 @@ mod tests {
#[test] #[test]
fn snapshot_updates_after_resolve_ticket() { fn snapshot_updates_after_resolve_ticket() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "first")); let _p1 = mb.enqueue(a, ticket(10, "first"));
let _p2 = mb.enqueue(a, ticket(11, "second")); let _p2 = mb.enqueue(a, ticket(11, "second"));
@ -572,7 +580,7 @@ mod tests {
application::diag::set_log_path(path.clone()); application::diag::set_log_path(path.clone());
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(777_001); let a = key(777_001);
let t = tid(777_010); let t = tid(777_010);
let _p = mb.enqueue(a, Ticket::new(t, "Main", "diag task")); let _p = mb.enqueue(a, Ticket::new(t, "Main", "diag task"));
mb.resolve(a, "done".to_owned()).expect("resolve ok"); mb.resolve(a, "done".to_owned()).expect("resolve ok");
@ -596,7 +604,7 @@ mod tests {
#[test] #[test]
fn snapshot_updates_after_cancel_head() { fn snapshot_updates_after_cancel_head() {
let mb = InMemoryMailbox::new(); let mb = InMemoryMailbox::new();
let a = agent(1); let a = key(1);
let _p1 = mb.enqueue(a, ticket(10, "head")); let _p1 = mb.enqueue(a, ticket(10, "head"));
let _p2 = mb.enqueue(a, ticket(11, "next")); let _p2 = mb.enqueue(a, ticket(11, "next"));

View File

@ -265,9 +265,7 @@ impl PluginPackageStore for FsPluginPackageStore {
} }
fn app_data_dir_label(&self) -> Option<String> { fn app_data_dir_label(&self) -> Option<String> {
self.root self.root.parent().map(|p| p.to_string_lossy().into_owned())
.parent()
.map(|p| p.to_string_lossy().into_owned())
} }
} }

View File

@ -140,6 +140,7 @@ mod tests {
/// tâches concurrentes à l'arrivée). /// tâches concurrentes à l'arrivée).
fn task_with(conv: &str) -> ScheduledTask { fn task_with(conv: &str) -> ScheduledTask {
ScheduledTask::ResumeAgent { ScheduledTask::ResumeAgent {
project_id: domain::ProjectId::from_uuid(Uuid::nil()),
agent_id: AgentId::from_uuid(Uuid::from_u128(1)), agent_id: AgentId::from_uuid(Uuid::from_u128(1)),
node_id: NodeId::from_uuid(Uuid::from_u128(2)), node_id: NodeId::from_uuid(Uuid::from_u128(2)),
conversation_id: Some(conv.to_owned()), conversation_id: Some(conv.to_owned()),

View File

@ -396,8 +396,7 @@ mod tests {
#[test] #[test]
fn parse_jsonl_event_error_accepts_raw_string() { fn parse_jsonl_event_error_accepts_raw_string() {
let event = let event = parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned())); assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
} }
} }

View File

@ -90,8 +90,8 @@ impl FsProfileStore {
async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> { async fn read_doc(&self) -> Result<ProfilesDoc, StoreError> {
match self.fs.read(&self.path()).await { match self.fs.read(&self.path()).await {
Ok(bytes) => { Ok(bytes) => {
let mut doc: ProfilesDoc = let mut doc: ProfilesDoc = serde_json::from_slice(&bytes)
serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))?; .map_err(|e| StoreError::Serialization(e.to_string()))?;
for profile in &mut doc.profiles { for profile in &mut doc.profiles {
if !profile.opencode_backend_is_consistent() { if !profile.opencode_backend_is_consistent() {
profile.opencode = None; profile.opencode = None;

View File

@ -99,10 +99,11 @@ impl FsSecretStore {
} }
Err(FsError::NotFound(_)) => { Err(FsError::NotFound(_)) => {
let mut key = [0_u8; KEY_LEN]; let mut key = [0_u8; KEY_LEN];
SystemRandom::new() SystemRandom::new().fill(&mut key).map_err(|_| {
.fill(&mut key) SecretStoreError::Crypto("failed to generate secret key".into())
.map_err(|_| SecretStoreError::Crypto("failed to generate secret key".into()))?; })?;
let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); let dir =
RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned());
self.fs self.fs
.create_dir_all(&dir) .create_dir_all(&dir)
.await .await
@ -228,7 +229,10 @@ mod tests {
impl TempDir { impl TempDir {
fn new(label: &str) -> Self { 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(); std::fs::create_dir_all(&root).unwrap();
Self(root) Self(root)
} }
@ -293,7 +297,10 @@ mod tests {
async fn key_file_has_owner_only_permissions_on_unix() { async fn key_file_has_owner_only_permissions_on_unix() {
let dir = TempDir::new("perms"); let dir = TempDir::new("perms");
let store = store(&dir); let store = store(&dir);
store.put(&SecretRef::new("secret-d"), "value").await.unwrap(); store
.put(&SecretRef::new("secret-d"), "value")
.await
.unwrap();
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;

View File

@ -5,7 +5,7 @@ use std::time::Duration;
use domain::{ use domain::{
AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, AgentId, AgentInbox, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource,
InputMediator, Ticket, TicketId, InputMediator, ProjectId, RuntimeAgentKey, Ticket, TicketId,
}; };
use infrastructure::{ use infrastructure::{
start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox, start_background_ready_inbox_bridge, BackgroundTaskReadyToDeliver, InMemoryMailbox,
@ -26,6 +26,14 @@ fn agent(n: u128) -> AgentId {
AgentId::from_uuid(Uuid::from_u128(n)) 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 { fn ticket_id(n: u128) -> TicketId {
TicketId::from_uuid(Uuid::from_u128(n)) 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) { fn make_busy(inbox: &MediatedInbox, target: AgentId) {
let ticket = Ticket::new(ticket_id(900), "active", "active turn"); 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] #[test]
@ -80,12 +88,12 @@ fn enqueue_message_while_busy_is_queued_not_rejected() {
make_busy(&inbox, target); make_busy(&inbox, target);
let receipt = inbox let receipt = inbox
.enqueue_message(target, user_item(1, target, "queued")) .enqueue_message(key(1, target), user_item(1, target, "queued"))
.unwrap(); .unwrap();
assert_eq!(receipt.status, InboxReceiptStatus::Queued); assert_eq!(receipt.status, InboxReceiptStatus::Queued);
assert_eq!(receipt.depth, 2); 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.depth, 2);
assert_eq!(snapshot.items.len(), 1); assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].body, "queued"); 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 first = user_item(1, target, "first");
let second = user_item(2, target, "second"); let second = user_item(2, target, "second");
inbox.enqueue_message(target, first.clone()).unwrap(); inbox
inbox.enqueue_message(target, second.clone()).unwrap(); .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(key(1, target)), Some(first));
assert_eq!(inbox.dequeue_next(target), Some(second)); assert_eq!(inbox.dequeue_next(key(1, target)), Some(second));
assert_eq!(inbox.dequeue_next(target), None); assert_eq!(inbox.dequeue_next(key(1, target)), None);
} }
#[test] #[test]
@ -113,7 +125,7 @@ fn overflow_normal_message_returns_inbox_full() {
make_busy(&inbox, target); make_busy(&inbox, target);
let err = inbox let err = inbox
.enqueue_message(target, user_item(1, target, "overflow")) .enqueue_message(key(1, target), user_item(1, target, "overflow"))
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(
@ -132,12 +144,12 @@ fn overflow_completion_is_deferred_not_lost_or_enqueued() {
make_busy(&inbox, target); make_busy(&inbox, target);
let receipt = inbox let receipt = inbox
.enqueue_message(target, completion_item(1, target, 42)) .enqueue_message(key(1, target), completion_item(1, target, 42))
.unwrap(); .unwrap();
assert_eq!(receipt.status, InboxReceiptStatus::Deferred); assert_eq!(receipt.status, InboxReceiptStatus::Deferred);
assert_eq!(receipt.depth, 1); assert_eq!(receipt.depth, 1);
assert!(inbox.snapshot(target).items.is_empty()); assert!(inbox.snapshot(key(1, target)).items.is_empty());
} }
#[test] #[test]
@ -146,13 +158,13 @@ fn snapshot_exposes_queue_depth_and_items() {
let target = agent(1); let target = agent(1);
inbox inbox
.enqueue_message(target, user_item(1, target, "first")) .enqueue_message(key(1, target), user_item(1, target, "first"))
.unwrap(); .unwrap();
inbox inbox
.enqueue_message(target, user_item(2, target, "second")) .enqueue_message(key(1, target), user_item(2, target, "second"))
.unwrap(); .unwrap();
let snapshot = inbox.snapshot(target); let snapshot = inbox.snapshot(key(1, target));
assert_eq!(snapshot.agent_id, target); assert_eq!(snapshot.agent_id, target);
assert_eq!(snapshot.depth, 2); assert_eq!(snapshot.depth, 2);
assert_eq!( assert_eq!(
@ -170,19 +182,38 @@ fn drain_removes_only_one_item_at_a_time() {
let inbox = inbox(10); let inbox = inbox(10);
let target = agent(1); let target = agent(1);
inbox inbox
.enqueue_message(target, user_item(1, target, "first")) .enqueue_message(key(1, target), user_item(1, target, "first"))
.unwrap(); .unwrap();
inbox inbox
.enqueue_message(target, user_item(2, target, "second")) .enqueue_message(key(1, target), user_item(2, target, "second"))
.unwrap(); .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.depth, 1);
assert_eq!(snapshot.items[0].body, "second"); 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] #[tokio::test]
async fn ready_bridge_enqueues_background_completion_item() { async fn ready_bridge_enqueues_background_completion_item() {
let inbox = Arc::new(inbox(10)); let inbox = Arc::new(inbox(10));
@ -192,13 +223,13 @@ async fn ready_bridge_enqueues_background_completion_item() {
tx.send(BackgroundTaskReadyToDeliver { tx.send(BackgroundTaskReadyToDeliver {
task_id: task_id(42), task_id: task_id(42),
project_id: domain::ProjectId::from_uuid(Uuid::from_u128(7)), project_id: project_id(7),
owner_agent_id: target, owner_agent_id: target,
}) })
.unwrap(); .unwrap();
tokio::time::sleep(Duration::from_millis(20)).await; 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.depth, 1);
assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion); assert_eq!(snapshot.items[0].kind, InboxItemKind::BackgroundCompletion);
assert_eq!( assert_eq!(

View File

@ -1146,7 +1146,8 @@ async fn stop_agent_call_closes_the_live_session() {
let (service, sessions) = build_service(contexts); let (service, sessions) = build_service(contexts);
// Pre-bind a live PTY session for the agent so StopAgent has something to close. // 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)); let session_id = SessionId::from_uuid(Uuid::from_u128(555));
sessions.insert( sessions.insert_in_project(
project().id,
PtyHandle { session_id }, PtyHandle { session_id },
TerminalSession::starting( TerminalSession::starting(
session_id, session_id,
@ -1156,7 +1157,9 @@ async fn stop_agent_call_closes_the_live_session() {
PtySize { rows: 24, cols: 80 }, 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 server = server(service);
let raw = tools_call(1, "idea_stop_agent", json!({ "target": "dev" })); 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. // CloseTerminal ran → the agent no longer has a live session.
assert!( 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" "stop_agent should have removed the session"
); );
} }

View File

@ -17,7 +17,7 @@ use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry}; use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource}; use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::SkillId; use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId}; use domain::ids::{AgentId, ProfileId, ProjectId, RuntimeAgentKey};
use domain::markdown::MarkdownDoc; use domain::markdown::MarkdownDoc;
use domain::ports::{ use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory, AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
@ -840,12 +840,13 @@ async fn ask_request_surfaces_reply_alongside_detail() {
let svc = Arc::clone(&service); let svc = Arc::clone(&service);
let proj = project(); let proj = project();
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
let ask_req = req.clone(); let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); 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). // Wait until the ask has enqueued its ticket (blocked awaiting the reply).
tokio::time::timeout(std::time::Duration::from_secs(10), async { 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; 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 svc = Arc::clone(&service);
let proj = project(); let proj = project();
let runtime_key = RuntimeAgentKey::new(proj.id, agent_id);
let ask_req = req.clone(); let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await }); let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
tokio::time::timeout(std::time::Duration::from_secs(10), async { 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; 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:?}"), 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` /// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`

View File

@ -7,7 +7,9 @@ use std::sync::Arc;
use domain::ids::ProfileId; use domain::ids::ProfileId;
use domain::ports::{FileSystem, ProfileStore, RemotePath, SecretRef, StoreError}; 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 infrastructure::{FsProfileStore, LocalFileSystem};
use uuid::Uuid; use uuid::Uuid;
@ -173,11 +175,20 @@ async fn profiles_file_is_camelcase_versioned() {
/// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode /// cloud provider (`opencodeProvider`) — the two mutually exclusive OpenCode
/// backends. A profile carrying both is inconsistent. /// backends. A profile carrying both is inconsistent.
fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) { fn opencode_backends() -> (OpenCodeConfig, OpenCodeProviderConfig) {
let local = let local = OpenCodeConfig::new(
OpenCodeConfig::new("http://localhost:8080/v1", None, "qwen3-coder-30b", None, None).unwrap(); "http://localhost:8080/v1",
let cloud = None,
OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", SecretRef::new("secret-cloud")) "qwen3-coder-30b",
.unwrap(); None,
None,
)
.unwrap();
let cloud = OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
SecretRef::new("secret-cloud"),
)
.unwrap();
(local, cloud) (local, cloud)
} }
@ -203,15 +214,21 @@ async fn read_doc_repairs_stale_opencode_when_both_sections_present() {
let fs = LocalFileSystem::new(); let fs = LocalFileSystem::new();
let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] }); let doc = serde_json::json!({ "version": 1, "profiles": [corrupted] });
fs.write(&tmp.child("profiles.json"), &serde_json::to_vec_pretty(&doc).unwrap()) fs.write(
.await &tmp.child("profiles.json"),
.unwrap(); &serde_json::to_vec_pretty(&doc).unwrap(),
)
.await
.unwrap();
let store = store(&tmp); let store = store(&tmp);
let listed = store.list().await.expect("read repairs instead of failing"); let listed = store.list().await.expect("read repairs instead of failing");
assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry"); assert_eq!(listed.len(), 1, "corrupted profile preserved as an entry");
let repaired = &listed[0]; 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!( assert_eq!(
repaired.opencode_provider.as_ref().unwrap().provider_id, repaired.opencode_provider.as_ref().unwrap().provider_id,
"anthropic", "anthropic",

View File

@ -1458,6 +1458,7 @@ async fn execute_launch_agent_for_ws(
request: LaunchAgentRequestDto, request: LaunchAgentRequestDto,
) -> Result<application::LaunchAgentOutput, ErrorDto> { ) -> Result<application::LaunchAgentOutput, ErrorDto> {
let project = resolve_project_readonly(&request.project_id, &state.app).await?; 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 agent_id = parse_agent_id(&request.agent_id)?;
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?; 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 { 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() { if let Ok(mut contexts) = state.app.resume_contexts.lock() {
contexts.insert( contexts.insert(
agent_id, domain::RuntimeAgentKey::new(project_id, agent_id),
backend::ResumeContext { backend::ResumeContext {
project: resume_project, project: resume_project,
rows: request.rows, rows: request.rows,
@ -1503,6 +1504,7 @@ async fn execute_launch_agent_for_ws(
if let Some(profile) = output.profile.as_ref() { if let Some(profile) = output.profile.as_ref() {
state.app.arm_turn_watch( state.app.arm_turn_watch(
project_id,
&watch_root, &watch_root,
agent_id, agent_id,
profile, profile,
@ -6067,8 +6069,13 @@ mod tests {
scrollback scrollback
); );
let aid = parse_agent_id(&agent_id).unwrap(); let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!( assert_eq!(
state.app.terminal_sessions.sessions_for_agent(&aid).len(), state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1 1
); );
@ -6122,8 +6129,13 @@ mod tests {
assert_eq!(second.kind, "terminal.attached"); assert_eq!(second.kind, "terminal.attached");
assert_eq!(attached_session_id(&second), first_session); assert_eq!(attached_session_id(&second), first_session);
let aid = parse_agent_id(&agent_id).unwrap(); let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!( assert_eq!(
state.app.terminal_sessions.sessions_for_agent(&aid).len(), state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1 1
); );
@ -6174,8 +6186,13 @@ mod tests {
assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell")); assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell"));
assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING"); assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING");
let aid = parse_agent_id(&agent_id).unwrap(); let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!( assert_eq!(
state.app.terminal_sessions.sessions_for_agent(&aid).len(), state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1 1
); );