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