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

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