fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user