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,
|
||||
|
||||
@ -58,8 +58,8 @@ pub use agent::{
|
||||
SaveOpenCodeProviderProfile, SaveOpenCodeProviderProfileInput,
|
||||
SaveOpenCodeProviderProfileOutput, SaveProfile, SaveProfileInput, SaveProfileOutput,
|
||||
SessionLimitService, StructuredRoutingMode, StructuredSessionDescriptor, TurnOutcome,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
|
||||
LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
|
||||
};
|
||||
pub use background::{
|
||||
BackgroundCommandArchive, CancelBackgroundTask, CancelBackgroundTaskOutput,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::background_task::{BackgroundTask, BackgroundTaskResult};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::AgentId;
|
||||
use domain::ids::{AgentId, RuntimeAgentKey};
|
||||
use domain::inbox::{AgentInbox, InboxItem, InboxItemKind, InboxSource};
|
||||
use domain::input::InputMediator;
|
||||
use domain::mailbox::{AgentMailbox, Ticket};
|
||||
@ -90,25 +90,26 @@ impl AgentWakeService {
|
||||
agent: AgentId,
|
||||
reason: WakeReason,
|
||||
) -> Result<(), WakeError> {
|
||||
let key = RuntimeAgentKey::new(project.id, agent);
|
||||
self.publish(DomainEvent::AgentWakeScheduled {
|
||||
project_id: project.id,
|
||||
agent_id: agent,
|
||||
});
|
||||
|
||||
if self.input.busy_state(agent).is_busy() {
|
||||
if self.input.busy_state(key).is_busy() {
|
||||
return Err(WakeError::AgentBusy { agent_id: agent });
|
||||
}
|
||||
|
||||
let Some(item) = self.inbox.dequeue_next(agent) else {
|
||||
let Some(item) = self.inbox.dequeue_next(key) else {
|
||||
return Ok(());
|
||||
};
|
||||
let delivery = self.delivery_from_item(item, &reason).await?;
|
||||
let ticket = Ticket::new(delivery.ticket_id, "IdeA", delivery.prompt.clone());
|
||||
let _pending = self.input.enqueue_silent(agent, ticket);
|
||||
let _pending = self.input.enqueue_silent(key, ticket);
|
||||
let guard = WakeTurnGuard::new(
|
||||
Arc::clone(&self.input),
|
||||
Arc::clone(&self.mailbox),
|
||||
agent,
|
||||
key,
|
||||
delivery.ticket_id,
|
||||
);
|
||||
|
||||
@ -133,12 +134,12 @@ impl AgentWakeService {
|
||||
owner_agent_id: agent,
|
||||
});
|
||||
}
|
||||
drain_reply_stream_with_readiness(stream, self.input.as_ref(), agent)
|
||||
drain_reply_stream_with_readiness(stream, self.input.as_ref(), key)
|
||||
.await
|
||||
.map_err(|err| WakeError::Session(err.to_string()))?;
|
||||
|
||||
self.mailbox.cancel_head(agent, delivery.ticket_id);
|
||||
self.input.mark_idle(agent);
|
||||
self.mailbox.cancel_head(key, delivery.ticket_id);
|
||||
self.input.mark_idle(key);
|
||||
guard.disarm();
|
||||
Ok(())
|
||||
}
|
||||
@ -220,7 +221,7 @@ struct WakeDelivery {
|
||||
struct WakeTurnGuard {
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn AgentMailbox>,
|
||||
agent: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
ticket: domain::mailbox::TicketId,
|
||||
armed: bool,
|
||||
}
|
||||
@ -229,7 +230,7 @@ impl WakeTurnGuard {
|
||||
fn new(
|
||||
input: Arc<dyn InputMediator>,
|
||||
mailbox: Arc<dyn AgentMailbox>,
|
||||
agent: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
ticket: domain::mailbox::TicketId,
|
||||
) -> Self {
|
||||
Self {
|
||||
|
||||
@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::ports::{AgentSession, PtyHandle};
|
||||
use domain::{AgentId, IssueRef, NodeId, SessionId, SessionKind, TerminalSession};
|
||||
use domain::{AgentId, IssueRef, NodeId, ProjectId, SessionId, SessionKind, TerminalSession};
|
||||
|
||||
/// Runtime family of a live agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@ -25,6 +25,8 @@ pub enum LiveSessionKind {
|
||||
/// Read-only coordinates of one live agent session.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct LiveSessionSnapshot {
|
||||
/// The project owning the live session.
|
||||
pub project_id: ProjectId,
|
||||
/// The agent owning the live session.
|
||||
pub agent_id: AgentId,
|
||||
/// The layout node currently hosting the session view.
|
||||
@ -38,6 +40,7 @@ pub struct LiveSessionSnapshot {
|
||||
/// A registered, live terminal: its PTY handle plus the domain snapshot.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Entry {
|
||||
project_id: ProjectId,
|
||||
handle: PtyHandle,
|
||||
session: TerminalSession,
|
||||
}
|
||||
@ -51,7 +54,7 @@ struct Entry {
|
||||
/// implementation.
|
||||
pub trait LiveAgentRegistry: Send + Sync {
|
||||
/// Whether `agent_id` currently has a live session in the registry.
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool;
|
||||
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool;
|
||||
|
||||
/// Whether `node_id` (a layout leaf) currently hosts a live session.
|
||||
///
|
||||
@ -75,8 +78,9 @@ pub struct TerminalSessions {
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for TerminalSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent_in_project(project_id, agent_id)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
@ -130,11 +134,16 @@ impl TerminalSessions {
|
||||
/// may take part in several threads, so this is the **plural** of
|
||||
/// [`Self::session_for_agent`]).
|
||||
#[must_use]
|
||||
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
|
||||
pub fn sessions_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Vec<SessionId> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.id)
|
||||
.collect()
|
||||
@ -142,13 +151,38 @@ impl TerminalSessions {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::sessions_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
|
||||
self.sessions_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Inserts a freshly-opened session.
|
||||
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
|
||||
pub fn insert_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
handle: PtyHandle,
|
||||
session: TerminalSession,
|
||||
) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
map.insert(session.id, Entry { handle, session });
|
||||
map.insert(
|
||||
session.id,
|
||||
Entry {
|
||||
project_id,
|
||||
handle,
|
||||
session,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::insert_in_project`].
|
||||
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
|
||||
self.insert_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), handle, session);
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`] for a session, if registered.
|
||||
#[must_use]
|
||||
pub fn handle(&self, id: &SessionId) -> Option<PtyHandle> {
|
||||
@ -179,14 +213,26 @@ impl TerminalSessions {
|
||||
/// one live session per agent, so the first match is *the* match. `find`
|
||||
/// short-circuits on it.
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
pub fn session_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<SessionId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::session_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Returns the [`NodeId`] of the live cell hosting a given agent, if any.
|
||||
///
|
||||
/// Companion to [`Self::session_for_agent`]: the launch guard needs the
|
||||
@ -194,25 +240,41 @@ impl TerminalSessions {
|
||||
/// [`crate::error::AppError::AgentAlreadyRunning`]. Unambiguous by the same
|
||||
/// one-live-session-per-agent invariant.
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
pub fn node_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<NodeId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.find(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
|
||||
.map(|e| e.session.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::node_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Lists every currently-live agent, its current host cell and session id.
|
||||
///
|
||||
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
|
||||
/// Used by the `list_live_agents` query so the UI can disable an agent that
|
||||
/// is already running elsewhere (it cannot be launched in a second cell).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
pub fn live_agents_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => {
|
||||
Some((agent_id, e.session.node_id, e.session.id))
|
||||
@ -224,6 +286,13 @@ impl TerminalSessions {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code should prefer
|
||||
/// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`].
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil()))
|
||||
}
|
||||
|
||||
/// Rebinds a live agent session to a new visible layout node without
|
||||
/// respawning the CLI process.
|
||||
///
|
||||
@ -232,20 +301,36 @@ impl TerminalSessions {
|
||||
/// in another cell updates only the view binding (`node_id`). The PTY handle,
|
||||
/// session id, scrollback and process stay untouched.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
pub fn rebind_agent_node_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<TerminalSession> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m.values_mut().find(
|
||||
|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
|
||||
|e| e.project_id == project_id
|
||||
&& matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
|
||||
)?;
|
||||
entry.session.node_id = node_id;
|
||||
Some(entry.session.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must pass `project_id`.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<TerminalSession> {
|
||||
self.rebind_agent_node_in_project(
|
||||
ProjectId::from_uuid(uuid::Uuid::nil()),
|
||||
agent_id,
|
||||
node_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`]s of every currently-registered session.
|
||||
///
|
||||
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||
@ -295,6 +380,8 @@ impl TerminalSessions {
|
||||
/// le `node_id` (cellule-vue, rebindable) pour offrir la **même** surface que
|
||||
/// [`TerminalSessions`].
|
||||
struct StructuredEntry {
|
||||
/// Projet propriétaire de la session runtime.
|
||||
project_id: ProjectId,
|
||||
/// La session vivante (ressource process/SDK), derrière le port domaine.
|
||||
session: Arc<dyn AgentSession>,
|
||||
/// L'agent IA pilotant cette session (invariant « 1 session vivante/agent »).
|
||||
@ -328,8 +415,9 @@ pub struct StructuredSessions {
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for StructuredSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent(agent_id).is_some()
|
||||
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.session_for_agent_in_project(project_id, agent_id)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
@ -352,12 +440,19 @@ impl StructuredSessions {
|
||||
|
||||
/// Enregistre une session fraîchement démarrée pour `agent_id`, hébergée par
|
||||
/// la cellule `node_id`. Clé par l'id de session ([`AgentSession::id`]).
|
||||
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
|
||||
pub fn insert_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
session: Arc<dyn AgentSession>,
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) {
|
||||
if let Ok(mut map) = self.entries.lock() {
|
||||
let id = session.id();
|
||||
map.insert(
|
||||
id,
|
||||
StructuredEntry {
|
||||
project_id,
|
||||
session,
|
||||
agent_id,
|
||||
node_id,
|
||||
@ -366,6 +461,17 @@ impl StructuredSessions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::insert_in_project`].
|
||||
pub fn insert(&self, session: Arc<dyn AgentSession>, agent_id: AgentId, node_id: NodeId) {
|
||||
self.insert_in_project(
|
||||
ProjectId::from_uuid(uuid::Uuid::nil()),
|
||||
session,
|
||||
agent_id,
|
||||
node_id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne la session enregistrée pour un id, si présente.
|
||||
#[must_use]
|
||||
pub fn session(&self, id: &SessionId) -> Option<Arc<dyn AgentSession>> {
|
||||
@ -434,36 +540,72 @@ impl StructuredSessions {
|
||||
/// Jumeau de [`TerminalSessions::session_for_agent`] : **non ambigu** par
|
||||
/// l'invariant « 1 session vivante/agent » — le premier match est *le* match.
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
|
||||
pub fn session_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| Arc::clone(&e.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::session_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn session_for_agent(&self, agent_id: &AgentId) -> Option<Arc<dyn AgentSession>> {
|
||||
self.session_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Retourne l'[`SessionId`] de la session vivante hébergeant `agent_id`, si any.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
pub fn session_id_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<SessionId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.session.id())
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::session_id_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
self.session_id_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Retourne le [`NodeId`] de la cellule vivante hébergeant `agent_id`, si any.
|
||||
///
|
||||
/// Jumeau de [`TerminalSessions::node_for_agent`].
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
pub fn node_for_agent_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<NodeId> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.find(|e| &e.agent_id == agent_id)
|
||||
.map(|e| e.node_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must use
|
||||
/// [`Self::node_for_agent_in_project`].
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.node_for_agent_in_project(ProjectId::from_uuid(uuid::Uuid::nil()), agent_id)
|
||||
}
|
||||
|
||||
/// Résout les coordonnées `(agent_id, node_id, conversation_id)` d'une session structurée par son
|
||||
/// [`SessionId`] (LS7, tap niveau 1 des limites de session, §21.10).
|
||||
///
|
||||
@ -473,10 +615,19 @@ impl StructuredSessions {
|
||||
/// passer à [`SessionLimitService::on_rate_limited`](crate::SessionLimitService) sur
|
||||
/// un signal `RateLimited`. `None` si l'id n'est pas (ou plus) une session vivante.
|
||||
#[must_use]
|
||||
pub fn meta_for_session(&self, id: &SessionId) -> Option<(AgentId, NodeId, Option<String>)> {
|
||||
pub fn meta_for_session(
|
||||
&self,
|
||||
id: &SessionId,
|
||||
) -> Option<(ProjectId, AgentId, NodeId, Option<String>)> {
|
||||
self.entries.lock().ok().and_then(|m| {
|
||||
m.get(id)
|
||||
.map(|e| (e.agent_id, e.node_id, e.session.conversation_id()))
|
||||
m.get(id).map(|e| {
|
||||
(
|
||||
e.project_id,
|
||||
e.agent_id,
|
||||
e.node_id,
|
||||
e.session.conversation_id(),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@ -485,17 +636,28 @@ impl StructuredSessions {
|
||||
/// Jumeau de [`TerminalSessions::live_agents`] : un tuple
|
||||
/// `(AgentId, NodeId, SessionId)` par session structurée vivante.
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
pub fn live_agents_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter(|e| e.project_id == project_id)
|
||||
.map(|e| (e.agent_id, e.node_id, e.session.id()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code should prefer
|
||||
/// [`Self::live_agents_in_project`] or [`LiveSessions::live_agent_snapshots`].
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.live_agents_in_project(ProjectId::from_uuid(uuid::Uuid::nil()))
|
||||
}
|
||||
|
||||
/// Rebinde la session vivante d'un agent vers une nouvelle cellule-vue sans
|
||||
/// redémarrer la conversation (« la cellule est une vue », §17.6).
|
||||
///
|
||||
@ -503,16 +665,33 @@ impl StructuredSessions {
|
||||
/// change ; la session, son id et sa conversation restent intacts. Retourne la
|
||||
/// session rebindée, ou `None` si l'agent n'a pas de session vivante.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node_in_project(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m
|
||||
.values_mut()
|
||||
.find(|e| e.project_id == project_id && &e.agent_id == agent_id)?;
|
||||
entry.node_id = node_id;
|
||||
Some(Arc::clone(&entry.session))
|
||||
})
|
||||
}
|
||||
|
||||
/// Legacy project-less test helper. Production code must pass `project_id`.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<Arc<dyn AgentSession>> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m.values_mut().find(|e| &e.agent_id == agent_id)?;
|
||||
entry.node_id = node_id;
|
||||
Some(Arc::clone(&entry.session))
|
||||
})
|
||||
self.rebind_agent_node_in_project(
|
||||
ProjectId::from_uuid(uuid::Uuid::nil()),
|
||||
agent_id,
|
||||
node_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Retire une session du registre, retournant la session si présente (pour que
|
||||
@ -605,25 +784,35 @@ impl LiveSessions {
|
||||
|
||||
/// L'[`SessionId`] de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn session_id_for_agent(&self, agent_id: &AgentId) -> Option<SessionId> {
|
||||
pub fn session_id_for_agent(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
agent_id: &AgentId,
|
||||
) -> Option<SessionId> {
|
||||
self.pty
|
||||
.session_for_agent(agent_id)
|
||||
.or_else(|| self.structured.session_id_for_agent(agent_id))
|
||||
.session_for_agent_in_project(project_id, agent_id)
|
||||
.or_else(|| {
|
||||
self.structured
|
||||
.session_id_for_agent_in_project(project_id, agent_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// La cellule hôte de la session vivante d'un agent, PTY **ou** structurée.
|
||||
#[must_use]
|
||||
pub fn node_for_agent(&self, agent_id: &AgentId) -> Option<NodeId> {
|
||||
pub fn node_for_agent(&self, project_id: ProjectId, agent_id: &AgentId) -> Option<NodeId> {
|
||||
self.pty
|
||||
.node_for_agent(agent_id)
|
||||
.or_else(|| self.structured.node_for_agent(agent_id))
|
||||
.node_for_agent_in_project(project_id, agent_id)
|
||||
.or_else(|| {
|
||||
self.structured
|
||||
.node_for_agent_in_project(project_id, agent_id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Tous les agents vivants des deux registres (PTY puis structurés).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
let mut all = self.pty.live_agents();
|
||||
all.extend(self.structured.live_agents());
|
||||
pub fn live_agents(&self, project_id: ProjectId) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
let mut all = self.pty.live_agents_in_project(project_id);
|
||||
all.extend(self.structured.live_agents_in_project(project_id));
|
||||
all
|
||||
}
|
||||
|
||||
@ -632,30 +821,48 @@ impl LiveSessions {
|
||||
pub fn live_agent_snapshots(&self) -> Vec<LiveSessionSnapshot> {
|
||||
let mut all: Vec<LiveSessionSnapshot> = self
|
||||
.pty
|
||||
.live_agents()
|
||||
.into_iter()
|
||||
.map(|(agent_id, node_id, session_id)| LiveSessionSnapshot {
|
||||
agent_id,
|
||||
node_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some(LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id,
|
||||
node_id: e.session.node_id,
|
||||
session_id: e.session.id,
|
||||
kind: LiveSessionKind::Pty,
|
||||
}),
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
all.extend(self.structured.live_agents().into_iter().map(
|
||||
|(agent_id, node_id, session_id)| LiveSessionSnapshot {
|
||||
agent_id,
|
||||
node_id,
|
||||
session_id,
|
||||
kind: LiveSessionKind::Structured,
|
||||
},
|
||||
));
|
||||
.unwrap_or_default();
|
||||
all.extend(
|
||||
self.structured
|
||||
.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.map(|e| LiveSessionSnapshot {
|
||||
project_id: e.project_id,
|
||||
agent_id: e.agent_id,
|
||||
node_id: e.node_id,
|
||||
session_id: e.session.id(),
|
||||
kind: LiveSessionKind::Structured,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
all
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for LiveSessions {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
self.pty.is_agent_live(agent_id) || self.structured.is_agent_live(agent_id)
|
||||
fn is_agent_live(&self, project_id: ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.pty.is_agent_live(project_id, agent_id)
|
||||
|| self.structured.is_agent_live(project_id, agent_id)
|
||||
}
|
||||
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::input::InputMediator;
|
||||
use domain::{AgentId, NodeId, Project, SessionId};
|
||||
use domain::{AgentId, NodeId, Project, RuntimeAgentKey, SessionId};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::orchestrator::OrchestratorService;
|
||||
@ -65,11 +65,11 @@ impl AttachLiveAgent {
|
||||
/// [`AppError::NotFound`] when the agent has no live session in either registry.
|
||||
pub fn execute(&self, input: AttachLiveAgentInput) -> Result<AttachLiveAgentOutput, AppError> {
|
||||
// PTY first, then structured (one-live-session-per-agent ⇒ at most one match).
|
||||
if let Some(session) = self
|
||||
.live
|
||||
.pty
|
||||
.rebind_agent_node(&input.agent_id, input.node_id)
|
||||
{
|
||||
if let Some(session) = self.live.pty.rebind_agent_node_in_project(
|
||||
input.project.id,
|
||||
&input.agent_id,
|
||||
input.node_id,
|
||||
) {
|
||||
return Ok(AttachLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
node_id: session.node_id,
|
||||
@ -77,11 +77,11 @@ impl AttachLiveAgent {
|
||||
kind: LiveSessionKind::Pty,
|
||||
});
|
||||
}
|
||||
if let Some(session) = self
|
||||
.live
|
||||
.structured
|
||||
.rebind_agent_node(&input.agent_id, input.node_id)
|
||||
{
|
||||
if let Some(session) = self.live.structured.rebind_agent_node_in_project(
|
||||
input.project.id,
|
||||
&input.agent_id,
|
||||
input.node_id,
|
||||
) {
|
||||
return Ok(AttachLiveAgentOutput {
|
||||
agent_id: input.agent_id,
|
||||
node_id: input.node_id,
|
||||
@ -168,28 +168,37 @@ impl StopLiveAgent {
|
||||
&self,
|
||||
input: StopLiveAgentInput,
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
self.stop_dependencies(input.agent_id).await;
|
||||
self.stop_dependencies(&input.project, input.agent_id).await;
|
||||
if let Some(mediator) = &self.input {
|
||||
mediator.preempt(input.agent_id);
|
||||
mediator.preempt(RuntimeAgentKey::new(input.project.id, input.agent_id));
|
||||
}
|
||||
self.stop_one(input.agent_id).await
|
||||
self.stop_one_in_project(&input.project, input.agent_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stop_dependencies(&self, agent_id: AgentId) {
|
||||
async fn stop_dependencies(&self, project: &Project, agent_id: AgentId) {
|
||||
let Some(waits) = &self.waits else {
|
||||
return;
|
||||
};
|
||||
for dep in waits.active_wait_dependencies(agent_id) {
|
||||
if let Some(mediator) = &self.input {
|
||||
mediator.preempt(dep);
|
||||
mediator.preempt(RuntimeAgentKey::new(project.id, dep));
|
||||
}
|
||||
let _ = self.stop_one(dep).await;
|
||||
let _ = self.stop_one_in_project(project, dep).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_one(&self, agent_id: AgentId) -> Result<StopLiveAgentOutput, AppError> {
|
||||
async fn stop_one_in_project(
|
||||
&self,
|
||||
project: &Project,
|
||||
agent_id: AgentId,
|
||||
) -> Result<StopLiveAgentOutput, AppError> {
|
||||
// PTY first: delegate to the existing close primitive (removes + kills).
|
||||
if let Some(session_id) = self.live.pty.session_for_agent(&agent_id) {
|
||||
if let Some(session_id) = self
|
||||
.live
|
||||
.pty
|
||||
.session_for_agent_in_project(project.id, &agent_id)
|
||||
{
|
||||
self.close
|
||||
.execute(CloseTerminalInput { session_id })
|
||||
.await?;
|
||||
@ -201,7 +210,11 @@ impl StopLiveAgent {
|
||||
}
|
||||
// Structured: remove from the registry first (so the uniqueness guard no
|
||||
// longer sees a live session), then shut the session down out of the lock.
|
||||
if let Some(session_id) = self.live.structured.session_id_for_agent(&agent_id) {
|
||||
if let Some(session_id) = self
|
||||
.live
|
||||
.structured
|
||||
.session_id_for_agent_in_project(project.id, &agent_id)
|
||||
{
|
||||
if let Some(session) = self.live.structured.remove(&session_id) {
|
||||
session
|
||||
.shutdown()
|
||||
|
||||
@ -461,7 +461,12 @@ impl GetProjectWorkState {
|
||||
input: GetProjectWorkStateInput,
|
||||
) -> Result<ProjectWorkState, AppError> {
|
||||
let manifest = self.contexts.load_manifest(&input.project).await?;
|
||||
let live_by_agent = live_by_agent(self.live.live_agent_snapshots());
|
||||
let live_by_agent = live_by_agent(
|
||||
self.live
|
||||
.live_agent_snapshots()
|
||||
.into_iter()
|
||||
.filter(|snapshot| snapshot.project_id == input.project.id),
|
||||
);
|
||||
let undelivered_completions = match &self.background_tasks {
|
||||
Some(store) => Some(store.list_undelivered_completions().await),
|
||||
None => None,
|
||||
@ -482,11 +487,12 @@ impl GetProjectWorkState {
|
||||
// Tickets are crossed with the busy state: only an agent absent from
|
||||
// the manifest is dropped (this loop only visits manifest entries), so
|
||||
// the manifest boundary is naturally preserved.
|
||||
let busy = self.input.busy_state(agent.id);
|
||||
let runtime_key = domain::RuntimeAgentKey::new(input.project.id, agent.id);
|
||||
let busy = self.input.busy_state(runtime_key);
|
||||
let busy_ticket = busy.ticket();
|
||||
let tickets = self
|
||||
.queue
|
||||
.queue_for(agent.id)
|
||||
.queue_for(runtime_key)
|
||||
.into_iter()
|
||||
.map(|snapshot| ticket_state(snapshot, busy_ticket))
|
||||
.collect();
|
||||
@ -810,7 +816,9 @@ fn preview(text: &str, max_chars: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn live_by_agent(snapshots: Vec<LiveSessionSnapshot>) -> HashMap<AgentId, LiveSessionSnapshot> {
|
||||
fn live_by_agent(
|
||||
snapshots: impl IntoIterator<Item = LiveSessionSnapshot>,
|
||||
) -> HashMap<AgentId, LiveSessionSnapshot> {
|
||||
let mut out = HashMap::new();
|
||||
for snapshot in snapshots {
|
||||
out.entry(snapshot.agent_id).or_insert(snapshot);
|
||||
|
||||
@ -77,10 +77,11 @@ impl ReconcileLiveState {
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] sur défaillance de chargement ou d'upsert du store.
|
||||
pub async fn execute(&self, _input: ReconcileLiveStateInput) -> Result<(), AppError> {
|
||||
pub async fn execute(&self, input: ReconcileLiveStateInput) -> Result<(), AppError> {
|
||||
let state = self.store.load().await?;
|
||||
let now_ms = u64::try_from(self.clock.now_millis()).unwrap_or(0);
|
||||
let reconciled = state.reconcile_orphans(|a| self.registry.is_agent_live(a), now_ms);
|
||||
let reconciled =
|
||||
state.reconcile_orphans(|a| self.registry.is_agent_live(input.project_id, a), now_ms);
|
||||
for entry in reconciled {
|
||||
self.store.upsert(entry).await?;
|
||||
}
|
||||
@ -125,7 +126,7 @@ mod tests {
|
||||
live: HashSet<AgentId>,
|
||||
}
|
||||
impl LiveAgentRegistry for FakeRegistry {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.live.contains(agent_id)
|
||||
}
|
||||
fn is_node_live(&self, _node_id: &NodeId) -> bool {
|
||||
|
||||
@ -1174,7 +1174,7 @@ fn seed_live_agent_session(
|
||||
size,
|
||||
);
|
||||
session.status = domain::SessionStatus::Running;
|
||||
sessions.insert(PtyHandle { session_id }, session);
|
||||
sessions.insert_in_project(project().id, PtyHandle { session_id }, session);
|
||||
}
|
||||
|
||||
fn nid(n: u128) -> domain::NodeId {
|
||||
@ -1220,7 +1220,7 @@ async fn launch_new_in_other_cell_refuses_when_agent_live_elsewhere() {
|
||||
|
||||
// No silent move, no respawn, registry untouched.
|
||||
assert_eq!(
|
||||
sessions.node_for_agent(&agent.id),
|
||||
sessions.node_for_agent_in_project(project().id, &agent.id),
|
||||
Some(host),
|
||||
"session stays pinned on its host node"
|
||||
);
|
||||
@ -1309,7 +1309,10 @@ async fn launch_other_cell_with_conversation_id_rebinds_no_respawn() {
|
||||
|
||||
assert_eq!(out.session.id, sid(42), "returns the existing session");
|
||||
assert_eq!(out.session.node_id, target, "view rebound to target cell");
|
||||
assert_eq!(sessions.node_for_agent(&agent.id), Some(target));
|
||||
assert_eq!(
|
||||
sessions.node_for_agent_in_project(project().id, &agent.id),
|
||||
Some(target)
|
||||
);
|
||||
assert_eq!(sessions.len(), before, "registry size is unchanged");
|
||||
assert!(pty.spawns().is_empty(), "no PTY spawn on explicit reattach");
|
||||
}
|
||||
@ -1328,7 +1331,9 @@ async fn launch_succeeds_after_session_removed() {
|
||||
// Live, then removed (close/exit).
|
||||
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
|
||||
sessions.remove(&sid(42));
|
||||
assert!(sessions.session_for_agent(&agent.id).is_none());
|
||||
assert!(sessions
|
||||
.session_for_agent_in_project(project().id, &agent.id)
|
||||
.is_none());
|
||||
|
||||
let mut input = launch_input(agent.id);
|
||||
input.node_id = Some(nid(2));
|
||||
|
||||
@ -7,7 +7,7 @@ use domain::background_task::{
|
||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState,
|
||||
BackgroundTaskWakePolicy,
|
||||
};
|
||||
use domain::ids::{AgentId, ProjectId, SessionId, TaskId};
|
||||
use domain::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId, TaskId};
|
||||
use domain::inbox::{
|
||||
AgentInbox, AgentInboxSnapshot, InboxError, InboxItem, InboxItemKind, InboxReceipt,
|
||||
InboxReceiptStatus, InboxSource,
|
||||
@ -30,6 +30,10 @@ fn agent(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(id(n))
|
||||
}
|
||||
|
||||
fn runtime_key(agent_id: AgentId) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(ProjectId::from_uuid(id(100)), agent_id)
|
||||
}
|
||||
|
||||
fn task_id(n: u128) -> TaskId {
|
||||
TaskId::from_uuid(id(n))
|
||||
}
|
||||
@ -93,44 +97,46 @@ fn completed_task(
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeInbox {
|
||||
queues: Mutex<HashMap<AgentId, VecDeque<InboxItem>>>,
|
||||
queues: Mutex<HashMap<RuntimeAgentKey, VecDeque<InboxItem>>>,
|
||||
}
|
||||
|
||||
impl AgentInbox for FakeInbox {
|
||||
fn enqueue_message(
|
||||
&self,
|
||||
agent_id: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
item: InboxItem,
|
||||
) -> Result<InboxReceipt, InboxError> {
|
||||
let mut queues = self.queues.lock().unwrap();
|
||||
let queue = queues.entry(agent_id).or_default();
|
||||
let queue = queues.entry(agent).or_default();
|
||||
let item_id = item.id;
|
||||
queue.push_back(item);
|
||||
Ok(InboxReceipt {
|
||||
item_id,
|
||||
agent_id,
|
||||
agent_id: agent.agent_id,
|
||||
runtime_key: agent,
|
||||
depth: queue.len(),
|
||||
status: InboxReceiptStatus::Queued,
|
||||
})
|
||||
}
|
||||
|
||||
fn dequeue_next(&self, agent_id: AgentId) -> Option<InboxItem> {
|
||||
fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem> {
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(agent_id)
|
||||
.entry(agent)
|
||||
.or_default()
|
||||
.pop_front()
|
||||
}
|
||||
|
||||
fn snapshot(&self, agent_id: AgentId) -> AgentInboxSnapshot {
|
||||
fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot {
|
||||
let queues = self.queues.lock().unwrap();
|
||||
let items = queues
|
||||
.get(&agent_id)
|
||||
.get(&agent)
|
||||
.map(|queue| queue.iter().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
AgentInboxSnapshot {
|
||||
agent_id,
|
||||
agent_id: agent.agent_id,
|
||||
runtime_key: agent,
|
||||
depth: items.len(),
|
||||
items,
|
||||
}
|
||||
@ -139,14 +145,14 @@ impl AgentInbox for FakeInbox {
|
||||
|
||||
#[derive(Default)]
|
||||
struct SharedTurnState {
|
||||
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
|
||||
tickets: Mutex<HashMap<AgentId, VecDeque<Ticket>>>,
|
||||
busy: Mutex<HashMap<RuntimeAgentKey, AgentBusyState>>,
|
||||
tickets: Mutex<HashMap<RuntimeAgentKey, VecDeque<Ticket>>>,
|
||||
}
|
||||
|
||||
impl SharedTurnState {
|
||||
fn force_busy(&self, agent_id: AgentId, ticket_id: TicketId) {
|
||||
self.busy.lock().unwrap().insert(
|
||||
agent_id,
|
||||
runtime_key(agent_id),
|
||||
AgentBusyState::Busy {
|
||||
ticket: ticket_id,
|
||||
since_ms: 1,
|
||||
@ -158,20 +164,20 @@ impl SharedTurnState {
|
||||
self.tickets
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent_id)
|
||||
.get(&runtime_key(agent_id))
|
||||
.map(VecDeque::len)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl InputMediator for SharedTurnState {
|
||||
fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply {
|
||||
self.enqueue_silent(agent_id, ticket)
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
self.enqueue_silent(agent, ticket)
|
||||
}
|
||||
|
||||
fn enqueue_silent(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
self.busy.lock().unwrap().insert(
|
||||
agent_id,
|
||||
agent,
|
||||
AgentBusyState::Busy {
|
||||
ticket: ticket.id,
|
||||
since_ms: 1,
|
||||
@ -180,43 +186,43 @@ impl InputMediator for SharedTurnState {
|
||||
self.tickets
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(agent_id)
|
||||
.entry(agent)
|
||||
.or_default()
|
||||
.push_back(ticket);
|
||||
PendingReply::new(Box::pin(async { Err(MailboxError::Cancelled) }))
|
||||
}
|
||||
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn preempt(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
fn mark_idle(&self, agent_id: AgentId) {
|
||||
fn mark_idle(&self, agent: RuntimeAgentKey) {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent_id, AgentBusyState::Idle);
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
}
|
||||
|
||||
fn busy_state(&self, agent_id: AgentId) -> AgentBusyState {
|
||||
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent_id)
|
||||
.get(&agent)
|
||||
.copied()
|
||||
.unwrap_or(AgentBusyState::Idle)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentMailbox for SharedTurnState {
|
||||
fn enqueue(&self, agent_id: AgentId, ticket: Ticket) -> PendingReply {
|
||||
<Self as InputMediator>::enqueue(self, agent_id, ticket)
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
<Self as InputMediator>::enqueue(self, agent, ticket)
|
||||
}
|
||||
|
||||
fn resolve(&self, _agent: AgentId, _result: String) -> Result<(), MailboxError> {
|
||||
fn resolve(&self, _agent: RuntimeAgentKey, _result: String) -> Result<(), MailboxError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cancel_head(&self, agent_id: AgentId, ticket_id: TicketId) {
|
||||
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
let mut tickets = self.tickets.lock().unwrap();
|
||||
if let Some(queue) = tickets.get_mut(&agent_id) {
|
||||
if let Some(queue) = tickets.get_mut(&agent) {
|
||||
if queue.front().is_some_and(|ticket| ticket.id == ticket_id) {
|
||||
queue.pop_front();
|
||||
}
|
||||
@ -395,7 +401,10 @@ async fn wake_if_idle_starts_turn_with_background_completion_prompt() {
|
||||
let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
|
||||
tasks.insert(completed_task(&project, owner, task_id, "build finished"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, task_id, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
service(inbox, turns, tasks, sessions)
|
||||
@ -427,7 +436,10 @@ async fn owner_busy_does_not_start_concurrent_wake_and_keeps_item_queued() {
|
||||
let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
|
||||
tasks.insert(completed_task(&project, owner, task_id, "done"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, task_id, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
turns.force_busy(owner, ticket(99));
|
||||
|
||||
@ -442,7 +454,7 @@ async fn owner_busy_does_not_start_concurrent_wake_and_keeps_item_queued() {
|
||||
|
||||
assert_eq!(err, WakeError::AgentBusy { agent_id: owner });
|
||||
assert!(session.prompts.lock().unwrap().is_empty());
|
||||
assert_eq!(inbox.snapshot(owner).depth, 1);
|
||||
assert_eq!(inbox.snapshot(runtime_key(owner)).depth, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -456,7 +468,10 @@ async fn absent_session_is_launched_or_reattached_by_provider() {
|
||||
let sessions = Arc::new(FakeSessionProvider::default());
|
||||
tasks.insert(completed_task(&project, owner, task_id, "done"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, task_id, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
service(inbox, turns, tasks, sessions.clone())
|
||||
@ -485,7 +500,10 @@ async fn completion_is_marked_delivered_after_successful_wake() {
|
||||
)));
|
||||
tasks.insert(completed_task(&project, owner, task_id, "done"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, task_id, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
service(inbox, turns, tasks.clone(), sessions)
|
||||
@ -512,7 +530,10 @@ async fn completion_is_marked_delivered_once_send_is_accepted_even_if_drain_fail
|
||||
let sessions = Arc::new(FakeSessionProvider::with_session(session.clone()));
|
||||
tasks.insert(completed_task(&project, owner, task_id, "done"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, task_id, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, task_id, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = service(inbox, turns, tasks.clone(), sessions)
|
||||
@ -543,10 +564,16 @@ async fn wake_drains_exactly_one_item_per_turn() {
|
||||
tasks.insert(completed_task(&project, owner, first, "first"));
|
||||
tasks.insert(completed_task(&project, owner, second, "second"));
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, first, ticket(20)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, first, ticket(20)),
|
||||
)
|
||||
.unwrap();
|
||||
inbox
|
||||
.enqueue_message(owner, completion_item(owner, second, ticket(21)))
|
||||
.enqueue_message(
|
||||
runtime_key(owner),
|
||||
completion_item(owner, second, ticket(21)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
service(inbox.clone(), turns.clone(), tasks, sessions)
|
||||
@ -559,6 +586,6 @@ async fn wake_drains_exactly_one_item_per_turn() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(session.prompts.lock().unwrap().len(), 1);
|
||||
assert_eq!(inbox.snapshot(owner).depth, 1);
|
||||
assert_eq!(inbox.snapshot(runtime_key(owner)).depth, 1);
|
||||
assert_eq!(turns.ticket_depth(owner), 0);
|
||||
}
|
||||
|
||||
@ -670,7 +670,7 @@ fn seed_live_agent_session(
|
||||
size,
|
||||
);
|
||||
session.status = domain::SessionStatus::Running;
|
||||
sessions.insert(PtyHandle { session_id }, session);
|
||||
sessions.insert_in_project(project().id, PtyHandle { session_id }, session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -1580,7 +1580,8 @@ async fn live_agent_is_killed_and_relaunched_in_same_cell() {
|
||||
SessionKind::Agent { agent_id } if agent_id == agent.id
|
||||
));
|
||||
assert_eq!(
|
||||
f.sessions.session_for_agent(&agent.id),
|
||||
f.sessions
|
||||
.session_for_agent_in_project(project().id, &agent.id),
|
||||
Some(sid(777)),
|
||||
"the registry now holds the relaunched session"
|
||||
);
|
||||
|
||||
@ -23,7 +23,7 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::drain_with_readiness;
|
||||
use domain::ids::AgentId;
|
||||
use domain::ids::{AgentId, ProjectId, RuntimeAgentKey};
|
||||
use domain::input::{AgentBusyState, InputMediator};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
@ -38,6 +38,10 @@ fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(agent: AgentId) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), agent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession scriptable (mono-usage), repris de send_blocking_d1.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -121,17 +125,20 @@ impl RecordingMediator {
|
||||
}
|
||||
|
||||
impl InputMediator for RecordingMediator {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
|
||||
// Jamais utilisé par drain_with_readiness ; un future qui ne résout pas.
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "preempt"));
|
||||
fn preempt(&self, agent: RuntimeAgentKey) {
|
||||
self.calls.lock().unwrap().push((agent.agent_id, "preempt"));
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
self.calls.lock().unwrap().push((agent, "mark_idle"));
|
||||
fn mark_idle(&self, agent: RuntimeAgentKey) {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((agent.agent_id, "mark_idle"));
|
||||
}
|
||||
fn busy_state(&self, _agent: AgentId) -> AgentBusyState {
|
||||
fn busy_state(&self, _agent: RuntimeAgentKey) -> AgentBusyState {
|
||||
AgentBusyState::Idle
|
||||
}
|
||||
}
|
||||
@ -169,7 +176,7 @@ async fn final_only_stream_unblocks_queue_via_mark_idle() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("done")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "tâche", None, &mediator, agent).await;
|
||||
let out = drain_with_readiness(&session, "tâche", None, &mediator, key(agent)).await;
|
||||
|
||||
assert_eq!(out, Ok("done".to_owned()), "le Final rend bien son contenu");
|
||||
assert_eq!(
|
||||
@ -200,7 +207,7 @@ async fn intermediate_events_do_not_mark_idle_only_final_does() {
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await;
|
||||
|
||||
assert_eq!(out, Ok("hello".to_owned()));
|
||||
assert_eq!(
|
||||
@ -227,7 +234,7 @@ async fn heartbeats_alone_never_mark_idle_before_final() {
|
||||
]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await;
|
||||
assert_eq!(out, Ok("fini".to_owned()));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 1);
|
||||
}
|
||||
@ -242,7 +249,7 @@ async fn stream_without_final_does_not_mark_idle_and_is_io_error() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![heartbeat(), delta("a"), tool("b")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await;
|
||||
assert!(
|
||||
matches!(out, Err(AgentSessionError::Io(_))),
|
||||
"flux épuisé sans Final ⇒ Io, obtenu {out:?}"
|
||||
@ -263,7 +270,7 @@ async fn send_error_is_propagated_and_no_mark_idle() {
|
||||
)));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, agent).await;
|
||||
let out = drain_with_readiness(&session, "x", None, &mediator, key(agent)).await;
|
||||
assert_eq!(out, Err(AgentSessionError::Decode("bad json".to_owned())));
|
||||
assert_eq!(mediator.mark_idle_count(agent), 0);
|
||||
}
|
||||
@ -276,7 +283,7 @@ async fn mark_idle_targets_the_drained_agent_only() {
|
||||
let session = ScriptedSession::new(Script::Stream(vec![final_("ok")]));
|
||||
let mediator = RecordingMediator::new();
|
||||
|
||||
let _ = drain_with_readiness(&session, "x", None, &mediator, drained).await;
|
||||
let _ = drain_with_readiness(&session, "x", None, &mediator, key(drained)).await;
|
||||
assert_eq!(mediator.mark_idle_count(drained), 1);
|
||||
assert_eq!(
|
||||
mediator.mark_idle_count(other),
|
||||
@ -328,7 +335,7 @@ async fn timeout_returns_timeout_no_mark_idle_session_alive() {
|
||||
"x",
|
||||
Some(Duration::from_millis(20)),
|
||||
&mediator,
|
||||
agent,
|
||||
key(agent),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(out, Err(AgentSessionError::Timeout));
|
||||
|
||||
@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, RuntimeAgentKey};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
|
||||
@ -647,6 +647,12 @@ fn pid(n: u128) -> ProfileId {
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn project_id() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000))
|
||||
}
|
||||
fn rkey(n: u128) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(project_id(), aid(n))
|
||||
}
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
@ -656,7 +662,7 @@ fn nid(n: u128) -> NodeId {
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
project_id(),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
@ -1201,68 +1207,69 @@ impl CompletionBus {
|
||||
}
|
||||
|
||||
impl AgentMailbox for TestMailbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<TurnResolution>();
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(agent)
|
||||
.entry(agent.agent_id)
|
||||
.or_default()
|
||||
.push_back((ticket, tx));
|
||||
PendingReply::new(Box::pin(async move {
|
||||
rx.await.map_err(|_| MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
|
||||
fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError> {
|
||||
let slot = {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
let queue = q
|
||||
.get_mut(&agent)
|
||||
.get_mut(&agent.agent_id)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
queue.pop_front().expect("non-empty")
|
||||
};
|
||||
self.completions
|
||||
.push(agent, TestCompletion::Replied(result.clone()));
|
||||
.push(agent.agent_id, TestCompletion::Replied(result.clone()));
|
||||
let _ = slot.1.send(TurnResolution::Replied(result));
|
||||
Ok(())
|
||||
}
|
||||
fn resolve_ticket(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
ticket_id: TicketId,
|
||||
result: String,
|
||||
) -> Result<(), MailboxError> {
|
||||
let slot = {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
let queue = q
|
||||
.get_mut(&agent)
|
||||
.get_mut(&agent.agent_id)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
let pos = queue
|
||||
.iter()
|
||||
.position(|(t, _)| t.id == ticket_id)
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
queue.remove(pos).expect("found position")
|
||||
};
|
||||
self.completions
|
||||
.push(agent, TestCompletion::Replied(result.clone()));
|
||||
.push(agent.agent_id, TestCompletion::Replied(result.clone()));
|
||||
let _ = slot.1.send(TurnResolution::Replied(result));
|
||||
Ok(())
|
||||
}
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
if let Some(queue) = q.get_mut(&agent) {
|
||||
if let Some(queue) = q.get_mut(&agent.agent_id) {
|
||||
if queue.front().map(|(t, _)| t.id) == Some(ticket_id) {
|
||||
queue.pop_front();
|
||||
self.completions.push(agent, TestCompletion::Cancelled);
|
||||
self.completions
|
||||
.push(agent.agent_id, TestCompletion::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
// Mirror the production adapter: head-only, idempotent, fire-and-forget-safe.
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
if let Some(queue) = q.get_mut(&agent) {
|
||||
if let Some(queue) = q.get_mut(&agent.agent_id) {
|
||||
if queue.front().map(|(t, _)| t.id) != Some(ticket_id) {
|
||||
return;
|
||||
}
|
||||
@ -1270,7 +1277,8 @@ impl AgentMailbox for TestMailbox {
|
||||
return; // receiver gone (human submit / timed-out caller): preserve head.
|
||||
}
|
||||
let (_, tx) = queue.pop_front().expect("head just matched");
|
||||
self.completions.push(agent, TestCompletion::NoReply);
|
||||
self.completions
|
||||
.push(agent.agent_id, TestCompletion::NoReply);
|
||||
let _ = tx.send(TurnResolution::ReturnedToPromptNoReply);
|
||||
}
|
||||
}
|
||||
@ -1304,11 +1312,11 @@ impl TestMediator {
|
||||
}
|
||||
}
|
||||
impl InputMediator for TestMediator {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
{
|
||||
let mut b = self.busy.lock().unwrap();
|
||||
let st = b.entry(agent).or_insert(AgentBusyState::Idle);
|
||||
let st = b.entry(agent.agent_id).or_insert(AgentBusyState::Idle);
|
||||
if !st.is_busy() {
|
||||
*st = AgentBusyState::Busy {
|
||||
ticket: ticket_id,
|
||||
@ -1316,7 +1324,7 @@ impl InputMediator for TestMediator {
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(handle) = self.handles.lock().unwrap().get(&agent).cloned() {
|
||||
if let Some(handle) = self.handles.lock().unwrap().get(&agent.agent_id).cloned() {
|
||||
let line = format!(
|
||||
"[IdeA · tâche de {} · ticket {}] {}\n",
|
||||
ticket.requester, ticket_id, ticket.task
|
||||
@ -1325,26 +1333,26 @@ impl InputMediator for TestMediator {
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
}
|
||||
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
|
||||
self.handles.lock().unwrap().insert(agent, handle);
|
||||
fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
|
||||
self.handles.lock().unwrap().insert(agent.agent_id, handle);
|
||||
}
|
||||
fn delivers_turn(&self, agent: AgentId) -> bool {
|
||||
self.handles.lock().unwrap().contains_key(&agent)
|
||||
fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool {
|
||||
self.handles.lock().unwrap().contains_key(&agent.agent_id)
|
||||
}
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
self.preempts.lock().unwrap().push(agent);
|
||||
fn preempt(&self, agent: RuntimeAgentKey) {
|
||||
self.preempts.lock().unwrap().push(agent.agent_id);
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
fn mark_idle(&self, agent: RuntimeAgentKey) {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
.insert(agent.agent_id, AgentBusyState::Idle);
|
||||
}
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent)
|
||||
.get(&agent.agent_id)
|
||||
.copied()
|
||||
.unwrap_or(AgentBusyState::Idle)
|
||||
}
|
||||
@ -1354,29 +1362,38 @@ impl InputMediator for TestMediator {
|
||||
/// `infrastructure::InMemoryConversationRegistry`.
|
||||
#[derive(Default)]
|
||||
struct TestConversations {
|
||||
by_pair: Mutex<HashMap<(String, String), ConversationId>>,
|
||||
by_pair: Mutex<HashMap<(ProjectId, String, String), ConversationId>>,
|
||||
by_id: Mutex<HashMap<ConversationId, Conversation>>,
|
||||
}
|
||||
impl TestConversations {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
fn key(a: ConversationParty, b: ConversationParty) -> (String, String) {
|
||||
fn key(
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> (ProjectId, String, String) {
|
||||
let s = |p: ConversationParty| match p {
|
||||
ConversationParty::User => "user".to_owned(),
|
||||
ConversationParty::Agent { agent_id } => agent_id.to_string(),
|
||||
};
|
||||
let (ka, kb) = (s(a), s(b));
|
||||
if ka <= kb {
|
||||
(ka, kb)
|
||||
(project_id, ka, kb)
|
||||
} else {
|
||||
(kb, ka)
|
||||
(project_id, kb, ka)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ConversationRegistry for TestConversations {
|
||||
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation {
|
||||
let key = Self::key(a, b);
|
||||
fn resolve(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> Conversation {
|
||||
let key = Self::key(project_id, a, b);
|
||||
let mut pairs = self.by_pair.lock().unwrap();
|
||||
if let Some(id) = pairs.get(&key).copied() {
|
||||
return self.by_id.lock().unwrap().get(&id).cloned().unwrap();
|
||||
@ -1696,7 +1713,7 @@ fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: Ses
|
||||
SessionKind::Agent { agent_id },
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
sessions.insert(PtyHandle { session_id }, session);
|
||||
sessions.insert_in_project(project_id(), PtyHandle { session_id }, session);
|
||||
}
|
||||
|
||||
/// Waits (bounded) for a condition to hold, yielding between polls.
|
||||
@ -2105,7 +2122,7 @@ async fn ask_target_returns_to_prompt_without_reply_is_a_typed_error() {
|
||||
|
||||
// Simulate the grace-window completion (target back at prompt, no idea_reply).
|
||||
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.complete_without_reply(aid(1), ticket);
|
||||
fx.mailbox.complete_without_reply(rkey(1), ticket);
|
||||
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2135,7 +2152,7 @@ async fn ask_reply_wins_then_late_completion_is_noop() {
|
||||
.await
|
||||
.expect("reply ok");
|
||||
// …then a late grace completion for the same ticket is a no-op.
|
||||
fx.mailbox.complete_without_reply(aid(1), ticket);
|
||||
fx.mailbox.complete_without_reply(rkey(1), ticket);
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2263,7 +2280,7 @@ async fn ask_cancelled_turn_does_not_harvest() {
|
||||
// Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
|
||||
// channel-closed error: no Response turn, hence no harvest.
|
||||
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), ticket);
|
||||
fx.mailbox.cancel_head(rkey(1), ticket);
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2287,7 +2304,7 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The delegated turn started ⇒ the target is Busy.
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"target Busy while processing the delegated turn"
|
||||
);
|
||||
|
||||
@ -2304,7 +2321,7 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
|
||||
// C5: the reply marked the emitter Idle (FIFO can advance) — no prompt pattern needed.
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"idea_reply is the explicit signal that frees the turn"
|
||||
);
|
||||
@ -2326,7 +2343,7 @@ async fn dropped_ask_future_frees_busy_target() {
|
||||
// Le tour a démarré : la cible est Busy, un ticket est en file.
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"cible Busy pendant le tour délégué"
|
||||
);
|
||||
|
||||
@ -2335,9 +2352,9 @@ async fn dropped_ask_future_frees_busy_target() {
|
||||
let _ = ask.await; // récolte la JoinError(Cancelled), ignorée.
|
||||
|
||||
// Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO.
|
||||
await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await;
|
||||
await_until(|| !fx.mediator.busy_state(rkey(1)).is_busy()).await;
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)"
|
||||
);
|
||||
@ -2372,7 +2389,7 @@ async fn second_delegation_delivered_after_dropped_ask() {
|
||||
let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée"
|
||||
);
|
||||
|
||||
@ -2387,7 +2404,7 @@ async fn second_delegation_delivered_after_dropped_ask() {
|
||||
.expect("ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"cible Idle après résolution du 2e tour"
|
||||
);
|
||||
@ -2406,12 +2423,12 @@ async fn cancelled_ask_marks_target_idle() {
|
||||
let svc = Arc::clone(&fx.service);
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(fx.mediator.busy_state(aid(1)).is_busy());
|
||||
assert!(fx.mediator.busy_state(rkey(1)).is_busy());
|
||||
|
||||
// Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en
|
||||
// erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
|
||||
let t = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), t);
|
||||
fx.mailbox.cancel_head(rkey(1), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask retourne vite sur canal fermé")
|
||||
@ -2423,7 +2440,7 @@ async fn cancelled_ask_marks_target_idle() {
|
||||
);
|
||||
// Le garde a ramené la cible Idle (la FIFO peut avancer).
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"branche erreur ⇒ cible Idle (garde RAII)"
|
||||
);
|
||||
@ -2449,7 +2466,7 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
);
|
||||
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "launched reply".to_owned())
|
||||
.resolve(rkey(1), "launched reply".to_owned())
|
||||
.expect("structured Final");
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2922,7 +2939,7 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
|
||||
|
||||
// Débloque l'ask via le Final structured.
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "done".to_owned())
|
||||
.resolve(rkey(1), "done".to_owned())
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
}
|
||||
@ -2957,7 +2974,7 @@ async fn f1_ask_without_provider_writes_minimal_mcp_json() {
|
||||
);
|
||||
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "done".to_owned())
|
||||
.resolve(rkey(1), "done".to_owned())
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
}
|
||||
@ -2991,7 +3008,7 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "codex ok".to_owned())
|
||||
.resolve(rkey(1), "codex ok".to_owned())
|
||||
.expect("structured completion ok");
|
||||
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(out.reply.as_deref(), Some("codex ok"));
|
||||
@ -3015,7 +3032,7 @@ async fn f2_ask_claude_target_passes_guard() {
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "ok claude".to_owned())
|
||||
.resolve(rkey(1), "ok claude".to_owned())
|
||||
.expect("structured completion ok");
|
||||
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
assert_eq!(out.reply.as_deref(), Some("ok claude"));
|
||||
@ -3153,12 +3170,15 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
project_id(),
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
let user_b = fx.conversations.resolve(
|
||||
project_id(),
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
assert_ne!(a_to_b.id, user_b.id, "A↔B is a distinct thread from User↔B");
|
||||
assert!(
|
||||
a_to_b.same_pair(
|
||||
@ -3371,7 +3391,7 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
|
||||
// Retire the head ticket (drops its sender) ⇒ the awaiting ask sees a closed
|
||||
// channel and returns a typed error, the SAME cleanup the turn timeout performs.
|
||||
fx.mailbox.cancel_head(aid(2), t);
|
||||
fx.mailbox.cancel_head(rkey(2), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask returns promptly once the channel closes")
|
||||
@ -3388,7 +3408,8 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
fx.sessions
|
||||
.session_for_agent_in_project(project_id(), &aid(2)),
|
||||
Some(sid(802)),
|
||||
"target stays alive for the next turn"
|
||||
);
|
||||
@ -3527,7 +3548,7 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
|
||||
// Unblock the delegation so the spawned task ends cleanly.
|
||||
fx.mailbox
|
||||
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
.cancel_head(rkey(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
let _ = timeout(TEST_GUARD, ask).await;
|
||||
}
|
||||
|
||||
@ -3821,7 +3842,10 @@ async fn run_ask_roundtrip(
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(&json)).await });
|
||||
await_until(|| fx.mailbox.pending(&reply_from) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(reply_from, result.to_owned())
|
||||
.resolve(
|
||||
RuntimeAgentKey::new(project_id(), reply_from),
|
||||
result.to_owned(),
|
||||
)
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -3933,6 +3957,7 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
let convs = TestConversations::new();
|
||||
let expected = convs
|
||||
.resolve(
|
||||
project_id(),
|
||||
ConversationParty::agent(a),
|
||||
ConversationParty::agent(aid(1)),
|
||||
)
|
||||
@ -4168,6 +4193,7 @@ struct NoopResumer;
|
||||
impl AgentResumer for NoopResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
_project_id: ProjectId,
|
||||
_agent_id: AgentId,
|
||||
_node_id: NodeId,
|
||||
_conversation_id: Option<String>,
|
||||
@ -4291,7 +4317,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
|
||||
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_none(),
|
||||
"cible structurée froide : aucune session avant l'ask"
|
||||
);
|
||||
|
||||
@ -4315,7 +4343,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
);
|
||||
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||
);
|
||||
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||
@ -4375,11 +4405,15 @@ async fn ask_structured_target_live_as_pty_keeps_visible_terminal() {
|
||||
assert_eq!(fx.factory.start_count(), 1, "headless session started");
|
||||
assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped");
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"target now has a structured session"
|
||||
);
|
||||
assert!(
|
||||
fx.sessions.session_for_agent(&aid(1)).is_some(),
|
||||
fx.sessions
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"target still has its visible PTY session"
|
||||
);
|
||||
}
|
||||
@ -4392,7 +4426,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
let resets_at_ms = 9_999_999;
|
||||
fx.structured.insert(
|
||||
fx.structured.insert_in_project(
|
||||
project_id(),
|
||||
Arc::new(RateLimitedSession {
|
||||
id: sid(9700),
|
||||
conversation_id: Some("engine-target".to_owned()),
|
||||
@ -4443,7 +4478,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() {
|
||||
async fn ask_structured_rate_limit_without_conversation_uses_human_fallback_no_resume() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
fx.structured.insert(
|
||||
fx.structured.insert_in_project(
|
||||
project_id(),
|
||||
Arc::new(RateLimitedSession {
|
||||
id: sid(9701),
|
||||
conversation_id: None,
|
||||
|
||||
@ -418,7 +418,10 @@ async fn save_then_list_then_delete() {
|
||||
let store = FakeProfileStore::default();
|
||||
let save = SaveProfile::new(Arc::new(store.clone()));
|
||||
let list = ListProfiles::new(Arc::new(store.clone()));
|
||||
let delete = DeleteProfile::new(Arc::new(store.clone()), Arc::new(FakeSecretStore::default()));
|
||||
let delete = DeleteProfile::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(FakeSecretStore::default()),
|
||||
);
|
||||
|
||||
let p = profile(1, "Claude", "claude");
|
||||
let saved = save
|
||||
@ -532,7 +535,10 @@ async fn save_opencode_provider_profile_drops_stale_local_backend() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(out.profile.opencode.is_none(), "stale local backend dropped");
|
||||
assert!(
|
||||
out.profile.opencode.is_none(),
|
||||
"stale local backend dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
out.profile.opencode_provider.as_ref().unwrap().provider_id,
|
||||
"anthropic"
|
||||
@ -571,7 +577,13 @@ async fn delete_profile_with_opencode_provider_purges_its_secret() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let secret_ref = saved.profile.opencode_provider.as_ref().unwrap().api_key_ref.clone();
|
||||
let secret_ref = saved
|
||||
.profile
|
||||
.opencode_provider
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.api_key_ref
|
||||
.clone();
|
||||
assert_eq!(
|
||||
secrets.get(&secret_ref).await.unwrap(),
|
||||
Some("sk-live-to-be-purged".to_owned())
|
||||
@ -707,8 +719,14 @@ async fn clone_opencode_profile_accepts_a_cloud_provider_seed() {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(cloud_seed.opencode.is_none(), "precondition: cloud seed has no local backend");
|
||||
assert!(cloud_seed.opencode_provider.is_some(), "precondition: cloud seed has a provider");
|
||||
assert!(
|
||||
cloud_seed.opencode.is_none(),
|
||||
"precondition: cloud seed has no local backend"
|
||||
);
|
||||
assert!(
|
||||
cloud_seed.opencode_provider.is_some(),
|
||||
"precondition: cloud seed has a provider"
|
||||
);
|
||||
|
||||
SaveProfile::new(Arc::new(store.clone()))
|
||||
.execute(SaveProfileInput {
|
||||
@ -787,12 +805,13 @@ async fn clone_opencode_profile_falls_back_to_catalogue_when_persisted_seed_is_n
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(squatter.structured_adapter, None, "precondition: not an OpenCode profile");
|
||||
assert_eq!(
|
||||
squatter.structured_adapter, None,
|
||||
"precondition: not an OpenCode profile"
|
||||
);
|
||||
|
||||
SaveProfile::new(Arc::new(store.clone()))
|
||||
.execute(SaveProfileInput {
|
||||
profile: squatter,
|
||||
})
|
||||
.execute(SaveProfileInput { profile: squatter })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -13,7 +13,7 @@ use std::sync::Mutex;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use application::{drain_with_readiness, drain_with_readiness_outcome, send_blocking, TurnOutcome};
|
||||
use domain::ids::AgentId;
|
||||
use domain::ids::{AgentId, ProjectId, RuntimeAgentKey};
|
||||
use domain::input::{AgentBusyState, InputMediator};
|
||||
use domain::mailbox::{PendingReply, Ticket};
|
||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||
@ -24,6 +24,10 @@ fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn key(n: u128) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), aid(n))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake AgentSession : `send` rejoue une liste fixe d'événements.
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -57,17 +61,17 @@ struct RecordingMediator {
|
||||
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 {
|
||||
PendingReply::new(Box::pin(std::future::pending()))
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -86,7 +90,7 @@ async fn outcome_rate_limited_some_without_final_is_graceful() {
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect("un tour limité est une fin gracieuse, pas une erreur");
|
||||
assert_eq!(
|
||||
@ -109,7 +113,7 @@ async fn outcome_rate_limited_none_without_final_is_graceful() {
|
||||
events: vec![ReplyEvent::RateLimited { resets_at_ms: None }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect("fin gracieuse");
|
||||
assert_eq!(out, TurnOutcome::RateLimited { resets_at_ms: None });
|
||||
@ -131,7 +135,7 @@ async fn outcome_rate_limited_then_final_is_completed() {
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
let out = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(out, TurnOutcome::Completed("fini".to_owned()));
|
||||
@ -147,7 +151,7 @@ async fn outcome_truncated_stream_without_final_or_ratelimit_is_io_error() {
|
||||
events: vec![ReplyEvent::TextDelta { text: "a".into() }],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness_outcome(&session, "go", None, &mediator, aid(1))
|
||||
let err = drain_with_readiness_outcome(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect_err("flux tronqué sans limite ⇒ erreur");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
@ -167,7 +171,7 @@ async fn drain_with_readiness_rate_limited_is_io_error() {
|
||||
}],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let err = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
let err = drain_with_readiness(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect_err("limite ⇒ Io sur la signature historique");
|
||||
assert!(matches!(err, AgentSessionError::Io(_)), "vu: {err:?}");
|
||||
@ -197,7 +201,7 @@ async fn drain_with_readiness_nominal_still_completes() {
|
||||
],
|
||||
};
|
||||
let mediator = RecordingMediator::default();
|
||||
let content = drain_with_readiness(&session, "go", None, &mediator, aid(1))
|
||||
let content = drain_with_readiness(&session, "go", None, &mediator, key(1))
|
||||
.await
|
||||
.expect("ok");
|
||||
assert_eq!(content, "fini");
|
||||
|
||||
@ -147,7 +147,7 @@ impl FakeLive {
|
||||
}
|
||||
|
||||
impl LiveAgentRegistry for FakeLive {
|
||||
fn is_agent_live(&self, agent_id: &AgentId) -> bool {
|
||||
fn is_agent_live(&self, _project_id: domain::ProjectId, agent_id: &AgentId) -> bool {
|
||||
self.agents.lock().unwrap().contains(agent_id)
|
||||
}
|
||||
fn is_node_live(&self, node_id: &NodeId) -> bool {
|
||||
|
||||
@ -555,7 +555,7 @@ impl AgentSessionFactory for FakeFactory {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use application::ProviderSessionProvider;
|
||||
use domain::{ConversationId, ProviderSessionStore};
|
||||
use domain::{ConversationId, ConversationParty, ProviderSessionStore};
|
||||
|
||||
/// In-memory [`ProviderSessionStore`] for the P8b launch tests: a
|
||||
/// `(conversation, provider_id) → resumable_id` map, observable after the launch.
|
||||
@ -659,9 +659,13 @@ fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
fn project_id() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000))
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
project_id(),
|
||||
"demo",
|
||||
ProjectPath::new(ROOT).unwrap(),
|
||||
RemoteRef::local(),
|
||||
@ -734,7 +738,7 @@ fn seed_live_pty_session(
|
||||
size,
|
||||
);
|
||||
session.status = domain::SessionStatus::Running;
|
||||
sessions.insert(PtyHandle { session_id }, session);
|
||||
sessions.insert_in_project(project_id(), PtyHandle { session_id }, session);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -815,12 +819,19 @@ async fn structured_launch_starts_session_registers_no_pty_spawn() {
|
||||
// La session est enregistrée dans le registre structuré, retrouvable par agent.
|
||||
let registered = f
|
||||
.structured
|
||||
.session_for_agent(&f.agent.id)
|
||||
.session_for_agent_in_project(project_id(), &f.agent.id)
|
||||
.expect("structured session registered");
|
||||
assert_eq!(registered.id(), sid(500), "session id is the factory's");
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(nid(3)));
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.node_for_agent_in_project(project_id(), &f.agent.id),
|
||||
Some(nid(3))
|
||||
);
|
||||
// Rien côté registre PTY.
|
||||
assert!(f.sessions.session_for_agent(&f.agent.id).is_none());
|
||||
assert!(f
|
||||
.sessions
|
||||
.session_for_agent_in_project(project_id(), &f.agent.id)
|
||||
.is_none());
|
||||
|
||||
// AgentLaunched publié avec l'id de session structurée.
|
||||
assert_eq!(
|
||||
@ -845,11 +856,15 @@ async fn structured_launch_starts_session_registers_no_pty_spawn() {
|
||||
SessionKind::Agent { agent_id } if agent_id == f.agent.id
|
||||
));
|
||||
// P8a (ARCHITECTURE §19.7) : la CELLULE porte l'**id de paire IdeA**, pas l'id
|
||||
// moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(User, agent)`
|
||||
// dérivé via `ConversationId::for_pair` = l'UUID de l'agent (`aid(1)`).
|
||||
// moteur. Cellule neuve, lancement direct (aucun requester) ⇒ `pair(project, User, agent)`.
|
||||
let expected_pair = ConversationId::for_project_pair(
|
||||
project_id(),
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(f.agent.id),
|
||||
);
|
||||
assert_eq!(
|
||||
out.assigned_conversation_id.as_deref(),
|
||||
Some("00000000-0000-0000-0000-000000000001"),
|
||||
Some(expected_pair.to_string().as_str()),
|
||||
"cell carries the IdeA pair id (pivot logique), not the engine resumable"
|
||||
);
|
||||
// L'id de session MOTEUR (resumable provider) part dans le cache séparé.
|
||||
@ -882,8 +897,15 @@ async fn non_structured_profile_takes_pty_path_unchanged() {
|
||||
);
|
||||
|
||||
// Session côté registre PTY, rien côté structuré.
|
||||
assert_eq!(f.sessions.session_for_agent(&f.agent.id), Some(sid(777)));
|
||||
assert!(f.structured.session_for_agent(&f.agent.id).is_none());
|
||||
assert_eq!(
|
||||
f.sessions
|
||||
.session_for_agent_in_project(project_id(), &f.agent.id),
|
||||
Some(sid(777))
|
||||
);
|
||||
assert!(f
|
||||
.structured
|
||||
.session_for_agent_in_project(project_id(), &f.agent.id)
|
||||
.is_none());
|
||||
|
||||
// output.structured = None ; session PTY classique.
|
||||
assert!(
|
||||
@ -937,7 +959,8 @@ async fn structured_launch_new_in_other_cell_refuses_when_live_elsewhere() {
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.node_for_agent(&f.agent.id),
|
||||
f.structured
|
||||
.node_for_agent_in_project(project_id(), &f.agent.id),
|
||||
Some(host),
|
||||
"session stays pinned on its host node A"
|
||||
);
|
||||
@ -964,7 +987,11 @@ async fn structured_relaunch_same_node_rebinds_no_second_start() {
|
||||
assert_eq!(f.factory.start_count(), 1, "no second factory.start");
|
||||
assert_eq!(f.pty.spawn_count(), 0, "still no pty spawn");
|
||||
assert_eq!(f.structured.len(), 1, "single live structured session");
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(host));
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.node_for_agent_in_project(project_id(), &f.agent.id),
|
||||
Some(host)
|
||||
);
|
||||
let desc = out.structured.expect("descriptor on rebind");
|
||||
assert_eq!(desc.session_id, sid(500), "same live session id");
|
||||
assert_eq!(desc.node_id, host);
|
||||
@ -1002,7 +1029,11 @@ async fn structured_relaunch_other_cell_with_conversation_id_rebinds() {
|
||||
1,
|
||||
"still a single live structured session"
|
||||
);
|
||||
assert_eq!(f.structured.node_for_agent(&f.agent.id), Some(target));
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.node_for_agent_in_project(project_id(), &f.agent.id),
|
||||
Some(target)
|
||||
);
|
||||
let desc = out.structured.expect("descriptor on rebind");
|
||||
assert_eq!(desc.session_id, sid(500), "same live session id");
|
||||
assert_eq!(desc.node_id, target);
|
||||
@ -1130,7 +1161,8 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
.start(&profile, &ctx, &cwd, &SessionPlan::None, None, &[], None)
|
||||
.await
|
||||
.expect("seed structured session");
|
||||
f.structured.insert(session, agent.id, host);
|
||||
f.structured
|
||||
.insert_in_project(project_id(), session, agent.id, host);
|
||||
}
|
||||
// La factory a maintenant été appelée 1 fois (le seed) ; reset logique : on
|
||||
// comptera les start APRÈS, donc on mémorise la base.
|
||||
@ -1182,8 +1214,16 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
||||
relaunched.node_id, host,
|
||||
"relaunch reopens in the same cell"
|
||||
);
|
||||
assert_eq!(f.structured.session_id_for_agent(&agent.id), Some(sid(601)));
|
||||
assert_eq!(f.structured.node_for_agent(&agent.id), Some(host));
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.session_id_for_agent_in_project(project_id(), &agent.id),
|
||||
Some(sid(601))
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.node_for_agent_in_project(project_id(), &agent.id),
|
||||
Some(host)
|
||||
);
|
||||
assert_eq!(
|
||||
f.structured.len(),
|
||||
1,
|
||||
@ -1230,8 +1270,15 @@ async fn swap_pty_live_session_keeps_a1_kill_behaviour() {
|
||||
);
|
||||
assert_eq!(relaunched.id, sid(777));
|
||||
// The relaunched session lives in the PTY registry, not the structured one.
|
||||
assert_eq!(f.sessions.session_for_agent(&agent.id), Some(sid(777)));
|
||||
assert!(f.structured.session_for_agent(&agent.id).is_none());
|
||||
assert_eq!(
|
||||
f.sessions
|
||||
.session_for_agent_in_project(project_id(), &agent.id),
|
||||
Some(sid(777))
|
||||
);
|
||||
assert!(f
|
||||
.structured
|
||||
.session_for_agent_in_project(project_id(), &agent.id)
|
||||
.is_none());
|
||||
assert_eq!(f.contexts.profile_of(&agent.id), Some(pid(2)));
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,9 @@ use async_trait::async_trait;
|
||||
|
||||
use application::{LiveAgentRegistry, LiveSessions, StructuredSessions, TerminalSessions};
|
||||
use domain::ports::{AgentSession, AgentSessionError, PtyHandle, ReplyStream};
|
||||
use domain::{AgentId, NodeId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession};
|
||||
use domain::{
|
||||
AgentId, NodeId, ProjectId, ProjectPath, PtySize, SessionId, SessionKind, TerminalSession,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- petits constructeurs déterministes ------------------------------------
|
||||
@ -33,6 +35,9 @@ fn sid(n: u128) -> SessionId {
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn pid(n: u128) -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn nid(n: u128) -> NodeId {
|
||||
NodeId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
@ -129,7 +134,7 @@ fn structured_meta_for_session_resolves_agent_node_and_conversation() {
|
||||
|
||||
assert_eq!(
|
||||
reg.meta_for_session(&s),
|
||||
Some((a, n, Some("conv-live".to_owned())))
|
||||
Some((pid(0), a, n, Some("conv-live".to_owned())))
|
||||
);
|
||||
|
||||
// Id inconnu (ou retiré) ⇒ None (jamais de panique sur une session morte).
|
||||
@ -158,11 +163,11 @@ fn structured_one_live_session_per_agent_invariant() {
|
||||
// L'agent n'a pas de session vivante avant insertion.
|
||||
let reg = StructuredSessions::new();
|
||||
let a = aid(10);
|
||||
assert!(!reg.is_agent_live(&a));
|
||||
assert!(!reg.is_agent_live(pid(0), &a));
|
||||
assert!(reg.session_for_agent(&a).is_none());
|
||||
|
||||
reg.insert(fake(sid(1)), a, nid(100));
|
||||
assert!(reg.is_agent_live(&a));
|
||||
assert!(reg.is_agent_live(pid(0), &a));
|
||||
|
||||
// `session_for_agent` est non ambigu : il rend LA session de l'agent.
|
||||
let resolved = reg.session_for_agent(&a).unwrap().id();
|
||||
@ -200,14 +205,14 @@ fn structured_live_agent_registry_impl() {
|
||||
let a = aid(10);
|
||||
let n = nid(100);
|
||||
|
||||
assert!(!reg.is_agent_live(&a));
|
||||
assert!(!reg.is_agent_live(pid(0), &a));
|
||||
assert!(!reg.is_node_live(&n));
|
||||
|
||||
reg.insert(fake(sid(1)), a, n);
|
||||
|
||||
assert!(reg.is_agent_live(&a));
|
||||
assert!(reg.is_agent_live(pid(0), &a));
|
||||
assert!(reg.is_node_live(&n));
|
||||
assert!(!reg.is_agent_live(&aid(999)));
|
||||
assert!(!reg.is_agent_live(pid(0), &aid(999)));
|
||||
assert!(!reg.is_node_live(&nid(999)));
|
||||
|
||||
// is_node_live suit le rebind (la cellule vivante change).
|
||||
@ -233,6 +238,17 @@ fn structured_sessions_snapshot_for_global_shutdown() {
|
||||
|
||||
/// Insère un agent PTY dans `TerminalSessions`.
|
||||
fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
|
||||
insert_pty_in_project(pty, pid(0), s, a, n);
|
||||
}
|
||||
|
||||
/// Insère un agent PTY dans `TerminalSessions` pour un projet explicite.
|
||||
fn insert_pty_in_project(
|
||||
pty: &TerminalSessions,
|
||||
project_id: ProjectId,
|
||||
s: SessionId,
|
||||
a: AgentId,
|
||||
n: NodeId,
|
||||
) {
|
||||
let session = TerminalSession::starting(
|
||||
s,
|
||||
n,
|
||||
@ -240,7 +256,7 @@ fn insert_pty(pty: &TerminalSessions, s: SessionId, a: AgentId, n: NodeId) {
|
||||
SessionKind::Agent { agent_id: a },
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
pty.insert(PtyHandle { session_id: s }, session);
|
||||
pty.insert_in_project(project_id, PtyHandle { session_id: s }, session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -252,10 +268,10 @@ fn aggregator_agent_live_via_structured_only() {
|
||||
let a = aid(10);
|
||||
structured.insert(fake(sid(1)), a, nid(100));
|
||||
|
||||
assert!(agg.is_agent_live(&a), "live in structured ⇒ true");
|
||||
assert!(agg.is_agent_live(pid(0), &a), "live in structured ⇒ true");
|
||||
assert!(agg.is_node_live(&nid(100)));
|
||||
assert_eq!(agg.session_id_for_agent(&a), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(&a), Some(nid(100)));
|
||||
assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -267,10 +283,10 @@ fn aggregator_agent_live_via_pty_only() {
|
||||
let a = aid(20);
|
||||
insert_pty(&pty, sid(2), a, nid(200));
|
||||
|
||||
assert!(agg.is_agent_live(&a), "live in PTY ⇒ true");
|
||||
assert!(agg.is_agent_live(pid(0), &a), "live in PTY ⇒ true");
|
||||
assert!(agg.is_node_live(&nid(200)));
|
||||
assert_eq!(agg.session_id_for_agent(&a), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(&a), Some(nid(200)));
|
||||
assert_eq!(agg.session_id_for_agent(pid(0), &a), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(pid(0), &a), Some(nid(200)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -280,11 +296,11 @@ fn aggregator_agent_absent_from_both_is_not_live() {
|
||||
let agg = LiveSessions::new(pty, structured);
|
||||
|
||||
let a = aid(30);
|
||||
assert!(!agg.is_agent_live(&a), "absent from both ⇒ false");
|
||||
assert!(!agg.is_agent_live(pid(0), &a), "absent from both ⇒ false");
|
||||
assert!(!agg.is_node_live(&nid(300)));
|
||||
assert!(agg.session_id_for_agent(&a).is_none());
|
||||
assert!(agg.node_for_agent(&a).is_none());
|
||||
assert!(agg.live_agents().is_empty());
|
||||
assert!(agg.session_id_for_agent(pid(0), &a).is_none());
|
||||
assert!(agg.node_for_agent(pid(0), &a).is_none());
|
||||
assert!(agg.live_agents(pid(0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -296,7 +312,7 @@ fn aggregator_live_agents_concatenates_pty_then_structured() {
|
||||
insert_pty(&pty, sid(1), aid(10), nid(100));
|
||||
structured.insert(fake(sid(2)), aid(20), nid(200));
|
||||
|
||||
let all = agg.live_agents();
|
||||
let all = agg.live_agents(pid(0));
|
||||
assert_eq!(all.len(), 2, "both registries contribute");
|
||||
// PTY d'abord, structuré ensuite (ordre documenté de l'agrégateur).
|
||||
assert_eq!(all[0], (aid(10), nid(100), sid(1)));
|
||||
@ -317,14 +333,37 @@ fn aggregator_resolution_prefers_pty_then_falls_back_to_structured() {
|
||||
structured.insert(fake(sid(2)), struct_agent, nid(200));
|
||||
|
||||
// Agent PTY : résolu par le registre PTY.
|
||||
assert_eq!(agg.session_id_for_agent(&pty_agent), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(&pty_agent), Some(nid(100)));
|
||||
assert_eq!(agg.session_id_for_agent(pid(0), &pty_agent), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(pid(0), &pty_agent), Some(nid(100)));
|
||||
// Agent structuré : fallback sur le registre structuré.
|
||||
assert_eq!(agg.session_id_for_agent(&struct_agent), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(&struct_agent), Some(nid(200)));
|
||||
assert_eq!(
|
||||
agg.session_id_for_agent(pid(0), &struct_agent),
|
||||
Some(sid(2))
|
||||
);
|
||||
assert_eq!(agg.node_for_agent(pid(0), &struct_agent), Some(nid(200)));
|
||||
|
||||
// is_node_live : OR sur les deux.
|
||||
assert!(agg.is_node_live(&nid(100)));
|
||||
assert!(agg.is_node_live(&nid(200)));
|
||||
assert!(!agg.is_node_live(&nid(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_agent_id_in_distinct_projects_has_isolated_live_sessions() {
|
||||
let pty = Arc::new(TerminalSessions::new());
|
||||
let structured = Arc::new(StructuredSessions::new());
|
||||
let agg = LiveSessions::new(Arc::clone(&pty), Arc::clone(&structured));
|
||||
let a = aid(10);
|
||||
|
||||
insert_pty_in_project(&pty, pid(1), sid(1), a, nid(100));
|
||||
structured.insert_in_project(pid(2), fake(sid(2)), a, nid(200));
|
||||
|
||||
assert!(agg.is_agent_live(pid(1), &a));
|
||||
assert!(agg.is_agent_live(pid(2), &a));
|
||||
assert_eq!(agg.session_id_for_agent(pid(1), &a), Some(sid(1)));
|
||||
assert_eq!(agg.node_for_agent(pid(1), &a), Some(nid(100)));
|
||||
assert_eq!(agg.session_id_for_agent(pid(2), &a), Some(sid(2)));
|
||||
assert_eq!(agg.node_for_agent(pid(2), &a), Some(nid(200)));
|
||||
assert_eq!(agg.live_agents(pid(1)), vec![(a, nid(100), sid(1))]);
|
||||
assert_eq!(agg.live_agents(pid(2)), vec![(a, nid(200), sid(2))]);
|
||||
}
|
||||
|
||||
@ -25,7 +25,8 @@ use domain::{
|
||||
BackgroundTaskResult, BackgroundTaskState, BackgroundTaskWakePolicy, ConversationId,
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, InputMediator, InputSource,
|
||||
ManifestEntry, MarkdownDoc, NodeId, ProfileId, Project, ProjectId, ProjectPath, PtySize,
|
||||
RemoteRef, SessionId, SessionKind, TaskId, TerminalSession, TicketId, TurnId, TurnRole,
|
||||
RemoteRef, RuntimeAgentKey, SessionId, SessionKind, TaskId, TerminalSession, TicketId, TurnId,
|
||||
TurnRole,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@ -129,21 +130,21 @@ impl FakeInput {
|
||||
}
|
||||
|
||||
impl InputMediator for FakeInput {
|
||||
fn enqueue(&self, _agent: AgentId, _ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, _agent: RuntimeAgentKey, _ticket: Ticket) -> PendingReply {
|
||||
let fut: Pin<Box<dyn Future<Output = Result<TurnResolution, MailboxError>> + Send>> =
|
||||
Box::pin(async { Err(MailboxError::Cancelled) });
|
||||
PendingReply::new(fut)
|
||||
}
|
||||
|
||||
fn preempt(&self, _agent: AgentId) {}
|
||||
fn preempt(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
fn mark_idle(&self, _agent: AgentId) {}
|
||||
fn mark_idle(&self, _agent: RuntimeAgentKey) {}
|
||||
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent)
|
||||
.get(&agent.agent_id)
|
||||
.copied()
|
||||
.unwrap_or(AgentBusyState::Idle)
|
||||
}
|
||||
@ -261,11 +262,11 @@ impl FakeQueue {
|
||||
}
|
||||
|
||||
impl AgentQueueSnapshot for FakeQueue {
|
||||
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot> {
|
||||
fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot> {
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent)
|
||||
.get(&agent.agent_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@ -446,7 +447,8 @@ fn insert_pty(
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) {
|
||||
sessions.insert(
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
session_id,
|
||||
@ -685,7 +687,8 @@ async fn workstate_attaches_live_pty_session_to_manifest_agent() {
|
||||
async fn workstate_attaches_live_structured_session_to_manifest_agent() {
|
||||
let a = agent(10, "alpha");
|
||||
let f = fixture(std::slice::from_ref(&a));
|
||||
f.structured.insert(fake_session(sid(2)), a.id, nid(200));
|
||||
f.structured
|
||||
.insert_in_project(f.project.id, fake_session(sid(2)), a.id, nid(200));
|
||||
|
||||
let out = f
|
||||
.usecase
|
||||
|
||||
@ -156,7 +156,8 @@ fn insert_pty(
|
||||
agent_id: AgentId,
|
||||
node_id: NodeId,
|
||||
) {
|
||||
sessions.insert(
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
PtyHandle { session_id },
|
||||
TerminalSession::starting(
|
||||
session_id,
|
||||
@ -175,7 +176,8 @@ fn insert_structured(
|
||||
node_id: NodeId,
|
||||
) -> Arc<AtomicBool> {
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
sessions.insert(
|
||||
sessions.insert_in_project(
|
||||
project().id,
|
||||
Arc::new(FakeSession {
|
||||
id: session_id,
|
||||
shutdown_called: Arc::clone(&flag),
|
||||
@ -210,8 +212,14 @@ fn attach_pty_rebinds_node_without_changing_session() {
|
||||
assert_eq!(out.node_id, nid(200), "view rebound to the new node");
|
||||
assert_eq!(out.kind, LiveSessionKind::Pty);
|
||||
// The registry reflects the new host node, same session.
|
||||
assert_eq!(f.pty.node_for_agent(&a), Some(nid(200)));
|
||||
assert_eq!(f.pty.session_for_agent(&a), Some(sid(1)));
|
||||
assert_eq!(
|
||||
f.pty.node_for_agent_in_project(project().id, &a),
|
||||
Some(nid(200))
|
||||
);
|
||||
assert_eq!(
|
||||
f.pty.session_for_agent_in_project(project().id, &a),
|
||||
Some(sid(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -232,7 +240,10 @@ fn attach_structured_rebinds_node() {
|
||||
assert_eq!(out.session_id, sid(2));
|
||||
assert_eq!(out.node_id, nid(300));
|
||||
assert_eq!(out.kind, LiveSessionKind::Structured);
|
||||
assert_eq!(f.structured.node_for_agent(&a), Some(nid(300)));
|
||||
assert_eq!(
|
||||
f.structured.node_for_agent_in_project(project().id, &a),
|
||||
Some(nid(300))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -309,7 +320,7 @@ async fn stop_pty_kills_and_removes_session() {
|
||||
// Delegated to the close primitive: process killed and registry emptied.
|
||||
assert_eq!(f.pty_port.kills(), vec![sid(1)]);
|
||||
assert!(f.pty.is_empty(), "live session removed from the registry");
|
||||
assert_eq!(f.pty.session_for_agent(&a), None);
|
||||
assert_eq!(f.pty.session_for_agent_in_project(project().id, &a), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -331,7 +342,11 @@ async fn stop_structured_shuts_down_and_removes_session() {
|
||||
assert_eq!(out.kind, LiveSessionKind::Structured);
|
||||
assert!(flag.load(Ordering::SeqCst), "session.shutdown() was called");
|
||||
assert!(f.structured.is_empty(), "live session removed");
|
||||
assert_eq!(f.structured.session_id_for_agent(&a), None);
|
||||
assert_eq!(
|
||||
f.structured
|
||||
.session_id_for_agent_in_project(project().id, &a),
|
||||
None
|
||||
);
|
||||
// No PTY was touched.
|
||||
assert!(f.pty_port.kills().is_empty());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user