fix(runtime): isolate agent state by project (#101)

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ use domain::project::ProjectPath;
use domain::{
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult,
BackgroundTaskState, BackgroundTaskWakePolicy, DomainEvent, OrchestratorCommand,
OrchestratorVisibility, ProfileId, Project, TaskId,
OrchestratorVisibility, ProfileId, Project, RuntimeAgentKey, TaskId,
};
use crate::conversation::RecordTurn;
@ -59,6 +59,10 @@ const DEFAULT_ROWS: u16 = 24;
/// See [`DEFAULT_ROWS`].
const DEFAULT_COLS: u16 = 80;
fn runtime_key(project: &Project, agent_id: AgentId) -> RuntimeAgentKey {
RuntimeAgentKey::new(project.id, agent_id)
}
/// Submit defaults for delegated prompts, after applying profile-specific
/// compatibility fallbacks for existing saved profiles.
fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
@ -234,7 +238,7 @@ fn resolve_turn_timeout(turn_timeout_ms: Option<u32>) -> Duration {
struct BusyTurnGuard {
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: AgentId,
agent: RuntimeAgentKey,
ticket: TicketId,
armed: bool,
}
@ -244,7 +248,7 @@ impl BusyTurnGuard {
fn new(
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
agent: AgentId,
agent: RuntimeAgentKey,
ticket: TicketId,
) -> Self {
Self {
@ -279,7 +283,7 @@ impl Drop for BusyTurnGuard {
// blocage à diagnostiquer.
crate::diag!(
"[rendezvous] busy-guard freed target agent {} (ticket {})",
self.agent,
self.agent.agent_id,
self.ticket,
);
}
@ -1116,7 +1120,7 @@ impl OrchestratorService {
from,
ticket,
result,
} => self.reply(from, ticket, result),
} => self.reply(project, from, ticket, result),
OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
@ -1501,7 +1505,10 @@ impl OrchestratorService {
}
};
if let Some(session_id) = self.sessions.session_for_agent(&agent_id) {
if let Some(session_id) = self
.sessions
.session_for_agent_in_project(project.id, &agent_id)
{
match visibility {
OrchestratorVisibility::Background => {
return Ok(OrchestratorOutcome {
@ -1516,12 +1523,14 @@ impl OrchestratorService {
// hôte est légitime (rebind de vue), mais viser un **autre** node
// pour un agent singleton déjà vivant est un second lancement ⇒
// refus `AgentAlreadyRunning`.
let host_node = self.sessions.node_for_agent(&agent_id);
let host_node = self
.sessions
.node_for_agent_in_project(project.id, &agent_id);
match ReattachDecision::resolve(Some(node_id), host_node, None) {
ReattachDecision::Rebind { node_id } => {
let session = self
.sessions
.rebind_agent_node(&agent_id, node_id)
.rebind_agent_node_in_project(project.id, &agent_id, node_id)
.ok_or_else(|| {
AppError::NotFound(format!(
"running session {session_id} for agent {name}"
@ -1620,7 +1629,6 @@ impl OrchestratorService {
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
let agent_id = agent.id;
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
// cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
// d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
@ -1647,7 +1655,7 @@ impl OrchestratorService {
// Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon
// User↔B. La session vivante est désormais keyée par conversation (lève
// `session-registry-agent-ambiguity`).
let conversation_id = self.resolve_conversation(requester, agent_id);
let conversation_id = self.resolve_conversation(project, requester, agent_id);
// Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
// pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
@ -1751,6 +1759,7 @@ impl OrchestratorService {
))
}
};
let agent_key = runtime_key(project, agent_id);
// Checkpoint Prompt (best-effort), AVANT de déplacer `task` dans le ticket.
let prompt_source = match requester {
@ -1783,7 +1792,7 @@ impl OrchestratorService {
// Timeout de tour piloté par profil (lot 2) + armement du seuil de stall, AVANT
// l'enqueue qui démarre le tour (le médiateur arme alors sa fenêtre de vivacité).
let turn_timeout = self.turn_timeout_for(project, agent_id).await;
let _pending = input.enqueue_silent(agent_id, ticket);
let _pending = input.enqueue_silent(agent_key, ticket);
let rendezvous_task = self
.start_rendezvous_task(
project,
@ -1809,7 +1818,7 @@ impl OrchestratorService {
// (drop)** — en ramenant la cible `Idle` au Drop (cf. [`BusyTurnGuard`]). C'est
// le fix de la cause racine du blocage `Busy` à vie.
let busy_guard =
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_id, ticket_id);
BusyTurnGuard::new(Arc::clone(input), Arc::clone(mailbox), agent_key, ticket_id);
// Rendezvous beacon (chemin structuré) : équivalent du « ask started » du chemin
// PTY. La cible n'a pas de PTY ; le tour se débloque uniquement sur le `Final`
@ -1840,7 +1849,7 @@ impl OrchestratorService {
&task,
None,
input.as_ref(),
agent_id,
agent_key,
announcement_publisher,
);
@ -1865,8 +1874,11 @@ impl OrchestratorService {
if let (Some(service), Some(structured)) =
(&self.session_limits, &self.structured)
{
if let Some(node_id) = structured.node_for_agent(&agent_id) {
if let Some(node_id) =
structured.node_for_agent_in_project(project.id, &agent_id)
{
service.on_rate_limited(
project.id,
agent_id,
node_id,
conversation_id,
@ -1982,8 +1994,8 @@ impl OrchestratorService {
// Succès : le `Final` a rendu la réponse. On retire explicitement le ticket de
// comptabilité (aucun `idea_reply` ne le fera), puis on désarme le garde RAII.
mailbox.cancel_head(agent_id, ticket_id);
input.mark_idle(agent_id);
mailbox.cancel_head(agent_key, ticket_id);
input.mark_idle(agent_key);
busy_guard.disarm();
// Checkpoint Response (best-effort), AVANT de déplacer `result`.
@ -2046,7 +2058,8 @@ impl OrchestratorService {
let target = target.as_str();
// User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask.
let conversation_id = self.resolve_conversation(None, agent_id);
let agent_key = runtime_key(project, agent_id);
let conversation_id = self.resolve_conversation(project, None, agent_id);
// Ensure the target is live for this thread and bind its input handle on the
// mediator (delivery path). Same call the ask path uses.
@ -2061,15 +2074,15 @@ impl OrchestratorService {
// de gate (livraison immédiate, sinon blocage indéfini).
let gate_cold_start = cold_launch && has_mcp;
if gate_cold_start {
input.mark_starting(agent_id);
input.mark_starting(agent_key);
}
input.bind_handle_with_submit(agent_id, handle, submit);
input.bind_handle_with_submit(agent_key, handle, submit);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The
// mediator emits AgentBusyChanged at the source on a starting turn.
let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text);
let _pending = input.enqueue(agent_id, ticket);
let _pending = input.enqueue(agent_key, ticket);
Ok(OrchestratorOutcome {
detail: format!("submitted human input to agent {target}"),
@ -2110,7 +2123,7 @@ impl OrchestratorService {
return Err(AppError::NotFound(format!("agent {agent_id}")));
}
input.preempt(agent_id);
input.preempt(runtime_key(project, agent_id));
Ok(OrchestratorOutcome {
detail: format!("interrupted agent {agent_id}"),
@ -2147,9 +2160,9 @@ impl OrchestratorService {
/// Libère le premier tour différé d'un agent **lancé à froid** quand son pont MCP se
/// connecte (readiness de démarrage). Pont entre l'McpServer (adapter entrant) et le
/// médiateur d'entrée. No-op si aucun médiateur n'est câblé ou si rien n'est différé.
pub fn release_agent_cold_start(&self, agent: domain::AgentId) {
pub fn release_agent_cold_start(&self, project: &Project, agent: domain::AgentId) {
if let Some(input) = &self.input {
input.release_cold_start(agent);
input.release_cold_start(runtime_key(project, agent));
}
}
@ -2174,10 +2187,15 @@ impl OrchestratorService {
/// cellule reçoit ses tours via l'événement `DelegationReady` (le front écrit) ; un
/// agent **headless** (délégué en arrière-plan, sans cellule) voit le médiateur écrire
/// lui-même la tâche dans son PTY — sinon le tour est perdu. No-op sans médiateur.
pub fn set_agent_front_attached(&self, agent: domain::AgentId, attached: bool) {
pub fn set_agent_front_attached(
&self,
project: &Project,
agent: domain::AgentId,
attached: bool,
) {
crate::diag!("[delivery] front attachment changed: agent={agent} attached={attached}");
if let Some(input) = &self.input {
input.set_front_attached(agent, attached);
input.set_front_attached(runtime_key(project, agent), attached);
} else {
crate::diag!(
"[delivery] front attachment ignored because input mediator is not wired: \
@ -2191,6 +2209,7 @@ impl OrchestratorService {
/// stable per-agent id derived from the target (legacy routing — never panics).
fn resolve_conversation(
&self,
project: &Project,
requester: Option<AgentId>,
target: AgentId,
) -> domain::conversation::ConversationId {
@ -2200,11 +2219,11 @@ impl OrchestratorService {
};
let right = ConversationParty::agent(target);
match &self.conversations {
Some(reg) => reg.resolve(left, right).id,
Some(reg) => reg.resolve(project.id, left, right).id,
// Repli pur déterministe partagé avec `LaunchAgent` (ARCHITECTURE §19.7,
// lot P8a) : la même paire dérive la même clé de conversation des deux
// côtés (sauvegarde du handoff ici, dérivation côté cellule là-bas).
None => domain::conversation::ConversationId::for_pair(left, right),
None => domain::conversation::ConversationId::for_project_pair(project.id, left, right),
}
}
@ -2223,6 +2242,7 @@ impl OrchestratorService {
/// matching ask) — typed, never a panic.
fn reply(
&self,
project: &Project,
from: AgentId,
ticket: Option<TicketId>,
result: String,
@ -2243,11 +2263,12 @@ impl OrchestratorService {
"idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(),
)
})?;
let from_key = runtime_key(project, from);
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
let correlation = match ticket {
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
None => mailbox.resolve(from, result),
Some(ticket_id) => mailbox.resolve_ticket(from_key, ticket_id, result),
None => mailbox.resolve(from_key, result),
};
// Rendezvous beacon (diagnostics) : un `idea_reply` est arrivé. Tracer s'il a
// corrélé à un ask en vol — un échec ici (« no matching ask ») signe une
@ -2269,7 +2290,7 @@ impl OrchestratorService {
// pairs with prompt-ready detection; whichever fires first frees the turn. No-op
// (and no spurious event) when the mediator is absent or `from` was already idle.
if let Some(input) = self.input.as_ref() {
input.mark_idle(from);
input.mark_idle(from_key);
}
Ok(OrchestratorOutcome {
detail: format!("reply from agent {from} delivered"),
@ -2300,10 +2321,10 @@ impl OrchestratorService {
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
let existing = self
.sessions
.session_for(conversation_id)
.or_else(|| self.sessions.session_for_agent(&agent_id));
let existing = self.sessions.session_for(conversation_id).or_else(|| {
self.sessions
.session_for_agent_in_project(project.id, &agent_id)
});
if let Some(session_id) = existing {
if let Some(handle) = self.sessions.handle(&session_id) {
// (Re)lier le fil à cette session vivante (idempotent). Réutilisation
@ -2334,11 +2355,14 @@ impl OrchestratorService {
})
.await?;
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
let session_id = self
.sessions
.session_for_agent_in_project(project.id, &agent_id)
.ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
self.bind_conversation_session(conversation_id, session_id);
@ -2373,7 +2397,7 @@ impl OrchestratorService {
structured: &Arc<StructuredSessions>,
) -> Result<Option<Arc<dyn domain::ports::AgentSession>>, AppError> {
// Cible déjà chaude : route directe (aucun lancement).
if let Some(session) = structured.session_for_agent(&agent_id) {
if let Some(session) = structured.session_for_agent_in_project(project.id, &agent_id) {
return Ok(Some(session));
}
@ -2413,7 +2437,7 @@ impl OrchestratorService {
// Le launcher a inséré la session dans le registre partagé : la relire.
structured
.session_for_agent(&agent_id)
.session_for_agent_in_project(project.id, &agent_id)
.map(Some)
.ok_or_else(|| {
AppError::Process(format!(
@ -2528,7 +2552,7 @@ impl OrchestratorService {
let session_id = self
.sessions
.session_for_agent(&agent_id)
.session_for_agent_in_project(project.id, &agent_id)
.ok_or_else(|| AppError::NotFound(format!("running session for agent {name}")))?;
self.close_terminal
@ -2719,7 +2743,7 @@ impl OrchestratorService {
async fn turn_timeout_for(&self, project: &Project, agent_id: AgentId) -> Duration {
let (stall_after_ms, turn_timeout_ms) = self.liveness_for_agent(project, agent_id).await;
if let Some(input) = &self.input {
input.set_stall_threshold(agent_id, stall_after_ms);
input.set_stall_threshold(runtime_key(project, agent_id), stall_after_ms);
}
resolve_turn_timeout(turn_timeout_ms)
}
@ -2982,18 +3006,18 @@ mod tests {
impl domain::input::InputMediator for SpyMediator {
fn enqueue(
&self,
_agent: AgentId,
_agent: RuntimeAgentKey,
_ticket: domain::mailbox::Ticket,
) -> domain::mailbox::PendingReply {
domain::mailbox::PendingReply::new(Box::pin(async {
Err(domain::mailbox::MailboxError::Cancelled)
}))
}
fn preempt(&self, _agent: AgentId) {}
fn mark_idle(&self, agent: AgentId) {
self.idled.lock().unwrap().push(agent);
fn preempt(&self, _agent: RuntimeAgentKey) {}
fn mark_idle(&self, agent: RuntimeAgentKey) {
self.idled.lock().unwrap().push(agent.agent_id);
}
fn busy_state(&self, _agent: AgentId) -> domain::input::AgentBusyState {
fn busy_state(&self, _agent: RuntimeAgentKey) -> domain::input::AgentBusyState {
domain::input::AgentBusyState::Idle
}
}
@ -3005,7 +3029,7 @@ mod tests {
impl domain::mailbox::AgentMailbox for SpyMailbox {
fn enqueue(
&self,
_agent: AgentId,
_agent: RuntimeAgentKey,
_ticket: domain::mailbox::Ticket,
) -> domain::mailbox::PendingReply {
domain::mailbox::PendingReply::new(Box::pin(async {
@ -3014,13 +3038,16 @@ mod tests {
}
fn resolve(
&self,
_agent: AgentId,
_agent: RuntimeAgentKey,
_result: String,
) -> Result<(), domain::mailbox::MailboxError> {
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
self.cancelled.lock().unwrap().push((agent, ticket_id));
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
self.cancelled
.lock()
.unwrap()
.push((agent.agent_id, ticket_id));
}
}
@ -3030,6 +3057,9 @@ mod tests {
fn tid(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
fn rkey(n: u128) -> RuntimeAgentKey {
RuntimeAgentKey::new(domain::ProjectId::from_uuid(uuid::Uuid::nil()), aid(n))
}
/// Drop d'un garde **armé** ⇒ `cancel_head` + `mark_idle` sur la cible (c'est le
/// comportement qui débloque un agent resté `Busy` sur un futur abandonné).
@ -3041,7 +3071,7 @@ mod tests {
let _g = BusyTurnGuard::new(
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
aid(1),
rkey(1),
tid(7),
);
} // Drop ici.
@ -3066,7 +3096,7 @@ mod tests {
let g = BusyTurnGuard::new(
Arc::clone(&med) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mb) as Arc<dyn domain::mailbox::AgentMailbox>,
aid(1),
rkey(1),
tid(7),
);
g.disarm();

View File

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

View File

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

View File

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

View File

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

View File

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