fix(runtime): isolate agent state by project (#101)

This commit is contained in:
2026-07-25 22:14:02 +02:00
parent 6a87c4635f
commit 6e98fd89f7
52 changed files with 1894 additions and 805 deletions

View File

@ -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));

View File

@ -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);
}

View File

@ -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"
);

View File

@ -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));

View File

@ -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,

View File

@ -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();

View File

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

View File

@ -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");

View File

@ -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 {

View File

@ -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)));
}

View File

@ -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))]);
}

View File

@ -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

View File

@ -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());
}