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

@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use application::{AgentResumer, AppError, SessionLimitService, RESUME_PROMPT};
use domain::ids::{AgentId, NodeId, ScheduleId};
use domain::ids::{AgentId, NodeId, ProjectId, ScheduleId};
use domain::ports::{Clock, EventBus, EventStream, ScheduledTask, Scheduler};
use domain::DomainEvent;
use uuid::Uuid;
@ -24,6 +24,9 @@ fn aid(n: u128) -> AgentId {
fn nid(n: u128) -> NodeId {
NodeId::from_uuid(Uuid::from_u128(n))
}
fn pid(n: u128) -> ProjectId {
ProjectId::from_uuid(Uuid::from_u128(n))
}
// ---------------------------------------------------------------------------
// Fakes des ports
@ -128,6 +131,7 @@ impl FakeResumer {
impl AgentResumer for FakeResumer {
async fn resume(
&self,
_project_id: ProjectId,
agent_id: AgentId,
node_id: NodeId,
conversation_id: Option<String>,
@ -185,8 +189,13 @@ const NOW: i64 = 1_700_000_000_000;
fn on_rate_limited_future_arms_and_emits_in_order() {
let env = env_at(NOW);
let reset = NOW + 60_000;
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(reset),
);
// Exactement un arm, avec la bonne échéance et la bonne tâche.
let armed = env.scheduler.armed();
@ -195,6 +204,7 @@ fn on_rate_limited_future_arms_and_emits_in_order() {
assert_eq!(
armed[0].1,
ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1),
node_id: nid(2),
conversation_id: Some("conv-1".to_owned()),
@ -225,7 +235,7 @@ fn on_rate_limited_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW);
let past = NOW - 60_000;
env.service
.on_rate_limited(aid(1), nid(2), None, Some(past));
.on_rate_limited(pid(1), aid(1), nid(2), None, Some(past));
let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1);
@ -252,7 +262,7 @@ fn on_rate_limited_past_reset_clamps_fire_at_to_now() {
fn on_rate_limited_without_reset_is_human_fallback_no_arm() {
let env = env_at(NOW);
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), None);
.on_rate_limited(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), None);
assert!(
env.scheduler.armed().is_empty(),
@ -282,10 +292,20 @@ fn on_rate_limited_twice_same_agent_dedups_cancelling_previous() {
let env = env_at(NOW);
let reset1 = NOW + 60_000;
let reset2 = NOW + 120_000;
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset1));
env.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset2));
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(reset1),
);
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(reset2),
);
// Deux arms (un par signal), ids distincts.
let issued = env.scheduler.issued();
@ -326,6 +346,7 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
let env = env_at(NOW);
// Arme d'abord (pour prouver que l'entrée est ensuite retirée).
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
@ -333,6 +354,7 @@ async fn execute_resume_calls_resumer_with_prompt_and_emits_resumed() {
);
let task = ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1),
node_id: nid(2),
conversation_id: Some("conv-1".to_owned()),
@ -373,6 +395,7 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
env.resumer.set_fail(true);
let task = ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1),
node_id: nid(2),
conversation_id: None,
@ -406,6 +429,7 @@ async fn execute_resume_propagates_error_without_emitting_resumed() {
fn cancel_resume_after_arm_returns_true_and_emits_cancelled() {
let env = env_at(NOW);
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
@ -448,6 +472,7 @@ fn cancel_resume_without_arm_is_false_no_event() {
fn cancel_resume_when_scheduler_already_fired_is_false_no_event() {
let env = env_at(NOW);
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
@ -484,7 +509,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
let env = env_at(NOW);
let reset = NOW + 90_000;
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
.confirm_human_resume(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), reset);
// Exactement un arm, bonne échéance, bonne tâche.
let armed = env.scheduler.armed();
@ -493,6 +518,7 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
assert_eq!(
armed[0].1,
ScheduledTask::ResumeAgent {
project_id: pid(1),
agent_id: aid(1),
node_id: nid(2),
conversation_id: Some("conv-1".to_owned()),
@ -524,7 +550,8 @@ fn confirm_human_resume_future_arms_and_emits_in_order() {
fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
let env = env_at(NOW);
let past = NOW - 30_000;
env.service.confirm_human_resume(aid(1), nid(2), None, past);
env.service
.confirm_human_resume(pid(1), aid(1), nid(2), None, past);
let armed = env.scheduler.armed();
assert_eq!(armed.len(), 1);
@ -554,13 +581,19 @@ fn confirm_human_resume_past_reset_clamps_fire_at_to_now() {
fn confirm_human_resume_after_auto_dedups_single_active_arm() {
let env = env_at(NOW);
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(NOW + 60_000),
);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 120_000);
env.service.confirm_human_resume(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 120_000,
);
let issued = env.scheduler.issued();
assert_eq!(
@ -600,9 +633,15 @@ fn confirm_human_resume_after_auto_dedups_single_active_arm() {
#[test]
fn auto_after_confirm_human_resume_dedups_single_active_arm() {
let env = env_at(NOW);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
env.service.confirm_human_resume(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 60_000,
);
env.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
@ -644,8 +683,13 @@ fn auto_after_confirm_human_resume_dedups_single_active_arm() {
#[test]
fn cancel_resume_after_confirm_human_resume_returns_true_and_emits_cancelled() {
let env = env_at(NOW);
env.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), NOW + 60_000);
env.service.confirm_human_resume(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
NOW + 60_000,
);
let issued = env.scheduler.issued();
assert!(
@ -670,13 +714,18 @@ fn confirm_human_resume_is_event_for_event_identical_to_auto_scheduled() {
let reset = NOW + 60_000;
let auto = env_at(NOW);
auto.service
.on_rate_limited(aid(1), nid(2), Some("conv-1".to_owned()), Some(reset));
auto.service.on_rate_limited(
pid(1),
aid(1),
nid(2),
Some("conv-1".to_owned()),
Some(reset),
);
let human = env_at(NOW);
human
.service
.confirm_human_resume(aid(1), nid(2), Some("conv-1".to_owned()), reset);
.confirm_human_resume(pid(1), aid(1), nid(2), Some("conv-1".to_owned()), reset);
// Même séquence d'events.
assert_eq!(