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 {
|
||||
|
||||
Reference in New Issue
Block a user