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

@ -22,7 +22,7 @@
use serde::{Deserialize, Serialize};
use crate::ids::{AgentId, SessionId};
use crate::ids::{AgentId, ProjectId, RuntimeAgentKey, SessionId};
/// Identifies one [`Conversation`].
///
@ -68,19 +68,38 @@ impl ConversationId {
/// Pour une paire `Agent↔Agent`, l'id est dérivé des deux UUID agent de façon
/// commutative (XOR), stable et déterministe.
#[must_use]
pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self {
pub fn for_project_pair(
project_id: ProjectId,
a: ConversationParty,
b: ConversationParty,
) -> Self {
match (a.as_agent(), b.as_agent()) {
// User↔Agent (un seul agent) : aligné sur le repli de `resolve_conversation`.
(Some(agent), None) | (None, Some(agent)) => Self::from_uuid(agent.as_uuid()),
(Some(agent), None) | (None, Some(agent)) => {
let key = RuntimeAgentKey::new(project_id, agent);
Self::from_uuid(uuid::Uuid::from_u128(
key.project_id.as_uuid().as_u128() ^ key.agent_id.as_uuid().as_u128(),
))
}
// Agent↔Agent : combinaison commutative des deux ids (insensible à l'ordre).
(Some(x), Some(y)) => {
let xor = x.as_uuid().as_u128() ^ y.as_uuid().as_u128();
let xor =
project_id.as_uuid().as_u128() ^ x.as_uuid().as_u128() ^ y.as_uuid().as_u128();
Self::from_uuid(uuid::Uuid::from_u128(xor))
}
// User↔User n'existe pas (invariant `Conversation`) : repli sûr non-panic.
(None, None) => Self::from_uuid(uuid::Uuid::nil()),
}
}
/// Legacy deterministic pair id without project scoping.
///
/// Runtime code must prefer [`Self::for_project_pair`]. Kept for persisted legacy
/// data and tests that intentionally exercise project-less values.
#[must_use]
pub fn for_pair(a: ConversationParty, b: ConversationParty) -> Self {
Self::for_project_pair(ProjectId::from_uuid(uuid::Uuid::nil()), a, b)
}
}
impl std::fmt::Display for ConversationId {
@ -322,7 +341,12 @@ pub trait ConversationRegistry: Send + Sync {
/// Get-or-create: returns the thread for the pair `{a, b}`, opening it
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
/// The same unordered pair always yields the same [`ConversationId`].
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
fn resolve(
&self,
project_id: ProjectId,
a: ConversationParty,
b: ConversationParty,
) -> Conversation;
/// Marks the conversation `id` `Live` with the given session reference.
fn bind_session(&self, id: ConversationId, session: SessionRef);

View File

@ -115,3 +115,34 @@ typed_id!(
/// Identifies a first-class background task.
TaskId
);
/// Runtime-only key for an agent scoped by its project.
///
/// `AgentId` is persisted inside a project and is not globally unique across
/// simultaneously opened projects. Any app-wide in-memory registry that tracks
/// live runtime state for an agent must use this key instead of `AgentId` alone.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeAgentKey {
/// Project that owns the runtime agent.
pub project_id: ProjectId,
/// Agent inside that project.
pub agent_id: AgentId,
}
impl RuntimeAgentKey {
/// Builds a scoped runtime key from its persisted identifiers.
#[must_use]
pub const fn new(project_id: ProjectId, agent_id: AgentId) -> Self {
Self {
project_id,
agent_id,
}
}
}
impl std::fmt::Display for RuntimeAgentKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.project_id, self.agent_id)
}
}

View File

@ -7,7 +7,7 @@
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::ids::{AgentId, TaskId};
use crate::ids::{AgentId, RuntimeAgentKey, TaskId};
use crate::mailbox::TicketId;
/// Default bounded inbox capacity per agent.
@ -114,6 +114,8 @@ pub struct InboxReceipt {
pub item_id: TicketId,
/// Target agent.
pub agent_id: AgentId,
/// Runtime-scoped target agent.
pub runtime_key: RuntimeAgentKey,
/// FIFO depth after the operation.
pub depth: usize,
/// Enqueue outcome.
@ -126,6 +128,8 @@ pub struct InboxReceipt {
pub struct AgentInboxSnapshot {
/// Target agent.
pub agent_id: AgentId,
/// Runtime-scoped target agent.
pub runtime_key: RuntimeAgentKey,
/// Number of queued inbox items.
pub depth: usize,
/// FIFO-ordered items.
@ -162,11 +166,15 @@ pub trait AgentInbox: Send + Sync {
///
/// # Errors
/// [`InboxError`] when the item is invalid or a normal message overflows.
fn enqueue_message(&self, agent: AgentId, item: InboxItem) -> Result<InboxReceipt, InboxError>;
fn enqueue_message(
&self,
agent: RuntimeAgentKey,
item: InboxItem,
) -> Result<InboxReceipt, InboxError>;
/// Pops the next queued inbox item, if any.
fn dequeue_next(&self, agent: AgentId) -> Option<InboxItem>;
fn dequeue_next(&self, agent: RuntimeAgentKey) -> Option<InboxItem>;
/// Returns the FIFO snapshot for `agent`.
fn snapshot(&self, agent: AgentId) -> AgentInboxSnapshot;
fn snapshot(&self, agent: RuntimeAgentKey) -> AgentInboxSnapshot;
}

View File

@ -14,7 +14,7 @@
use serde::{Deserialize, Serialize};
use crate::ids::AgentId;
use crate::ids::{AgentId, RuntimeAgentKey};
use crate::mailbox::{PendingReply, Ticket, TicketId};
use crate::ports::PtyHandle;
@ -162,7 +162,7 @@ pub trait InputMediator: Send + Sync {
/// cell (sole owner of the terminal) runs the write-portal handshake and writes the
/// text + submit sequence through the single PTY writer. The mediator stays the
/// authority of the FIFO/busy state and correlation only.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply;
/// Headless/system enqueue: appends `ticket` to the same FIFO and marks the agent
/// busy, but does **not** deliver any text to the human terminal surface.
@ -175,7 +175,7 @@ pub trait InputMediator: Send + Sync {
///
/// Default keeps compatibility for simple mediators; production overrides it to
/// suppress delivery.
fn enqueue_silent(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
fn enqueue_silent(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
self.enqueue(agent, ticket)
}
@ -186,7 +186,7 @@ pub trait InputMediator: Send + Sync {
/// the orchestrator calls this once it has resolved/launched the agent's live
/// session for the target conversation. Default: no-op (a mediator that does not
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
fn bind_handle(&self, _agent: RuntimeAgentKey, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also records the target's
/// [`SubmitConfig`] (ARCHITECTURE §20.3) so the adapter can echo
@ -203,7 +203,12 @@ pub trait InputMediator: Send + Sync {
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the submit config.
/// The infra adapter overrides it to stash the submit config.
fn bind_handle_with_submit(&self, agent: AgentId, handle: PtyHandle, _submit: SubmitConfig) {
fn bind_handle_with_submit(
&self,
agent: RuntimeAgentKey,
handle: PtyHandle,
_submit: SubmitConfig,
) {
self.bind_handle(agent, handle);
}
@ -212,7 +217,7 @@ pub trait InputMediator: Send + Sync {
/// for source compatibility; it now always returns `false`. Callers should stop
/// branching on it (the orchestrator no longer falls back to its own PTY write).
#[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool {
fn delivers_turn(&self, _agent: RuntimeAgentKey) -> bool {
false
}
@ -228,7 +233,7 @@ pub trait InputMediator: Send + Sync {
/// (chemin chaud, fallback sûr, zéro régression).
///
/// Default: no-op (a mediator that does not gate cold starts).
fn mark_starting(&self, _agent: AgentId) {}
fn mark_starting(&self, _agent: RuntimeAgentKey) {}
/// Signal de **readiness de démarrage** : le pont MCP de `agent` vient de se
/// connecter (son CLI est up et a chargé les outils `idea_*`). Si un premier tour
@ -240,7 +245,7 @@ pub trait InputMediator: Send + Sync {
/// watcher prompt-ready PTY ayant été supprimé.
///
/// Default: no-op (médiateur qui ne gate pas les démarrages à froid).
fn release_cold_start(&self, _agent: AgentId) {}
fn release_cold_start(&self, _agent: RuntimeAgentKey) {}
/// Déclare si une **cellule terminal du frontend** est montée pour `agent`
/// (`true` au montage du write-portal, `false` au démontage). C'est le frontend
@ -253,14 +258,14 @@ pub trait InputMediator: Send + Sync {
///
/// Default: no-op (médiateur sans notion de cellule front — tout passe par
/// l'événement, comportement historique).
fn set_front_attached(&self, _agent: AgentId, _attached: bool) {}
fn set_front_attached(&self, _agent: RuntimeAgentKey, _attached: bool) {}
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
fn preempt(&self, agent: RuntimeAgentKey);
/// Marks `agent` free (explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
fn mark_idle(&self, agent: RuntimeAgentKey);
/// **End-of-turn** signal: the agent's transcript just recorded a completed turn
/// (the [`crate::ports::TurnWatcher`] fired), and **no** `idea_reply` carried a
@ -273,7 +278,7 @@ pub trait InputMediator: Send + Sync {
/// Replaces the former prompt-ready watcher branch verbatim; only the **trigger**
/// changed (transcript `turn_duration` instead of a PTY prompt sigil). Default:
/// [`InputMediator::mark_idle`] (a mediator with no mailbox/grace just advances).
fn turn_ended(&self, agent: AgentId) {
fn turn_ended(&self, agent: RuntimeAgentKey) {
self.mark_idle(agent);
}
@ -287,7 +292,7 @@ pub trait InputMediator: Send + Sync {
/// Default: no-op (a mediator that does not track liveness). The infra adapter
/// `MediatedInbox` overrides it to refresh `last_seen` and publish an
/// `AgentLivenessChanged{Stalled→Alive}` recovery on the first late battement.
fn mark_alive(&self, _agent: AgentId) {}
fn mark_alive(&self, _agent: RuntimeAgentKey) {}
/// Declares the agent's **stall threshold** (its profile's
/// [`crate::profile::LivenessStrategy::stall_after_ms`]) so the stall detector knows
@ -298,10 +303,10 @@ pub trait InputMediator: Send + Sync {
/// zero regression).
///
/// Default: no-op (a mediator that does not track liveness).
fn set_stall_threshold(&self, _agent: AgentId, _stall_after_ms: Option<u32>) {}
fn set_stall_threshold(&self, _agent: RuntimeAgentKey, _stall_after_ms: Option<u32>) {}
/// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState;
}
#[cfg(test)]

View File

@ -76,8 +76,8 @@ mod validation;
pub use error::DomainError;
pub use ids::{
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, ScheduleId,
SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
ScheduleId, SessionId, SkillId, SprintId, TabId, TaskId, TemplateId, WindowId,
};
pub use project::{Project, ProjectPath};

View File

@ -30,7 +30,7 @@ use std::pin::Pin;
use std::task::{Context, Poll};
use crate::conversation::ConversationId;
use crate::ids::AgentId;
use crate::ids::{AgentId, RuntimeAgentKey};
use crate::input::InputSource;
/// A read-only, cloned view of one queued [`Ticket`] in a target agent's FIFO.
@ -71,7 +71,7 @@ pub trait AgentQueueSnapshot: Send + Sync {
///
/// An agent with no queue yields an empty `Vec`. Positions are recomputed from
/// the current order (`0` = head). Pure read: the queue is left untouched.
fn queue_for(&self, agent: AgentId) -> Vec<QueuedTicketSnapshot>;
fn queue_for(&self, agent: RuntimeAgentKey) -> Vec<QueuedTicketSnapshot>;
}
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
@ -283,7 +283,7 @@ pub trait AgentMailbox: Send + Sync {
/// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
/// feeds a result to this ticket (once it reaches the head and is answered), or
/// to [`MailboxError::Cancelled`] if the reply channel closes first.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply;
/// Resolves the request at the **head** of `agent`'s FIFO with `result`,
/// waking its awaiting [`PendingReply`] and removing it from the queue.
@ -294,7 +294,7 @@ pub trait AgentMailbox: Send + Sync {
/// # Errors
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
/// `idea_reply` with no matching ask in flight).
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>;
fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError>;
/// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the
@ -314,7 +314,7 @@ pub trait AgentMailbox: Send + Sync {
/// (and, for the default, when `agent`'s queue is empty).
fn resolve_ticket(
&self,
agent: AgentId,
agent: RuntimeAgentKey,
_ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
@ -326,7 +326,7 @@ pub trait AgentMailbox: Send + Sync {
///
/// A no-op when the head is a different ticket (the timed-out one was already
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId);
/// Completes the turn of the ticket `ticket_id` **without** a reply, waking its
/// awaiting [`PendingReply`] with [`TurnResolution::ReturnedToPromptNoReply`].
@ -346,7 +346,7 @@ pub trait AgentMailbox: Send + Sync {
///
/// The default is a no-op: a mailbox that cannot wake a pending caller early simply
/// falls back to the caller's timeout net (no behavioural change for it).
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
let _ = (agent, ticket_id);
}
}

View File

@ -658,6 +658,8 @@ pub enum ScheduledTask {
/// via [`SessionPlan::Resume`] avec un prompt de reprise court (logique côté
/// application, lot LS4). Porte exactement le pivot de reprise model-agnostique.
ResumeAgent {
/// Projet hôte de l'agent à reprendre.
project_id: ProjectId,
/// L'agent à reprendre.
agent_id: AgentId,
/// La cellule (nœud du layout) qui héberge sa session.

View File

@ -1415,9 +1415,7 @@ mod mcp_tests {
#[test]
fn opencode_provider_config_rejects_empty_fields() {
let secret_ref = crate::ports::SecretRef::new("secret-1");
assert!(
OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err()
);
assert!(OpenCodeProviderConfig::new("", "claude-sonnet-5", secret_ref.clone()).is_err());
assert!(OpenCodeProviderConfig::new("anthropic", "", secret_ref).is_err());
}
@ -1448,7 +1446,12 @@ mod mcp_tests {
#[test]
fn profile_with_opencode_provider_round_trips_camelcase() {
let provider = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-anthropic")).unwrap();
let provider = OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
crate::ports::SecretRef::new("secret-anthropic"),
)
.unwrap();
let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
.with_opencode_provider(provider.clone());
@ -1469,7 +1472,12 @@ mod mcp_tests {
None,
)
.unwrap();
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
let cloud = OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
crate::ports::SecretRef::new("secret-cloud"),
)
.unwrap();
let only_local = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::OpenCode)
@ -1504,7 +1512,12 @@ mod mcp_tests {
None,
)
.unwrap();
let cloud = OpenCodeProviderConfig::new("anthropic", "claude-sonnet-5", crate::ports::SecretRef::new("secret-cloud")).unwrap();
let cloud = OpenCodeProviderConfig::new(
"anthropic",
"claude-sonnet-5",
crate::ports::SecretRef::new("secret-cloud"),
)
.unwrap();
// Setting the cloud provider clears a previously-set local one.
let cloud_wins = profile_without_mcp()
@ -1513,7 +1526,10 @@ mod mcp_tests {
.with_opencode_provider(cloud.clone());
assert!(cloud_wins.opencode_backend_is_consistent());
assert!(cloud_wins.opencode.is_none(), "stale local backend dropped");
assert_eq!(cloud_wins.opencode_provider.as_ref().unwrap().provider_id, "anthropic");
assert_eq!(
cloud_wins.opencode_provider.as_ref().unwrap().provider_id,
"anthropic"
);
// Setting the local provider clears a previously-set cloud one.
let local_wins = profile_without_mcp()
@ -1521,13 +1537,21 @@ mod mcp_tests {
.with_opencode_provider(cloud)
.with_opencode(local);
assert!(local_wins.opencode_backend_is_consistent());
assert!(local_wins.opencode_provider.is_none(), "stale cloud backend dropped");
assert!(
local_wins.opencode_provider.is_none(),
"stale cloud backend dropped"
);
assert!(local_wins.opencode.is_some());
}
#[test]
fn opencode_provider_config_serialises_no_local_model_server_id_leak() {
let config = OpenCodeProviderConfig::new("openrouter", "some-model", crate::ports::SecretRef::new("secret-openrouter")).unwrap();
let config = OpenCodeProviderConfig::new(
"openrouter",
"some-model",
crate::ports::SecretRef::new("secret-openrouter"),
)
.unwrap();
let json = serde_json::to_string(&config).expect("serialise");
assert!(!json.contains("localModelServerId"));
assert!(!json.contains("baseURL"));