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,