feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
236
crates/domain/src/fileguard.rs
Normal file
236
crates/domain/src/fileguard.rs
Normal file
@ -0,0 +1,236 @@
|
||||
//! File-access guard (cadrage « FileGuard », lot C6).
|
||||
//!
|
||||
//! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns:
|
||||
//! an agent's `.md` context, the project's global context, and a memory note.
|
||||
//! N concurrent readers **or** a single exclusive writer per resource; the global
|
||||
//! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the
|
||||
//! orchestrator may `acquire_write` it — any other party must *propose* instead and
|
||||
//! receives [`GuardError::Forbidden`].
|
||||
//!
|
||||
//! ## Cooperative scope (cadrage §9.5)
|
||||
//!
|
||||
//! This guard corrects collisions **inside the IdeA path** (access through the MCP
|
||||
//! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can
|
||||
//! still write these `.md`/memory files directly through the filesystem, bypassing
|
||||
//! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS
|
||||
//! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of
|
||||
//! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent.
|
||||
//!
|
||||
//! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects
|
||||
//! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`],
|
||||
//! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`)
|
||||
//! provides the actual reader/writer locks.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::ids::AgentId;
|
||||
use crate::memory::MemorySlug;
|
||||
|
||||
/// The **bounded, closed** set of resources the [`FileGuard`] arbitrates.
|
||||
///
|
||||
/// Closed by construction: only an agent's context, the global project context, and
|
||||
/// a memory note are guardable. Anything outside this enum is **not** guarded (and
|
||||
/// must not be routed through the guard).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum GuardedResource {
|
||||
/// One agent's `.md` context file.
|
||||
AgentContext(AgentId),
|
||||
/// The project's global context (`CLAUDE.md`/root context).
|
||||
///
|
||||
/// **Single-writer**: only the orchestrator may write it; every other party is
|
||||
/// limited to *proposing* a change (cf. [`GuardError::Forbidden`]).
|
||||
ProjectContext,
|
||||
/// A single memory note, keyed by its slug.
|
||||
Memory(MemorySlug),
|
||||
}
|
||||
|
||||
impl GuardedResource {
|
||||
/// Whether this resource is the single-writer global project context.
|
||||
#[must_use]
|
||||
pub const fn is_project_context(&self) -> bool {
|
||||
matches!(self, Self::ProjectContext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised when acquiring a guard lease.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum GuardError {
|
||||
/// The lease could not be taken because the resource is held incompatibly
|
||||
/// (a writer wanted while readers/a writer hold it, or vice-versa) and the
|
||||
/// caller asked for a **non-blocking** acquire. Blocking acquires serialize
|
||||
/// instead of returning this.
|
||||
#[error("guarded resource is busy")]
|
||||
Busy,
|
||||
/// A party that is **not** the orchestrator attempted to `acquire_write` the
|
||||
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
|
||||
/// change for validation rather than writing directly.
|
||||
#[error("writing the global project context is reserved to the orchestrator; propose instead")]
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
/// A RAII **read** lease: holding it guarantees shared read access to the resource;
|
||||
/// dropping it releases the reader slot. N read leases may coexist on one resource.
|
||||
///
|
||||
/// The lease owns an opaque release guard (a boxed handle the adapter fills with its
|
||||
/// lock guard). The domain only knows the lease releases on drop.
|
||||
#[must_use = "a read lease releases the guard as soon as it is dropped"]
|
||||
pub struct ReadLease {
|
||||
_guard: Box<dyn Send + Sync>,
|
||||
}
|
||||
|
||||
impl ReadLease {
|
||||
/// Wraps an adapter-provided release guard into a domain [`ReadLease`].
|
||||
///
|
||||
/// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`);
|
||||
/// the domain treats it opaquely and only relies on its `Drop` to release.
|
||||
#[must_use]
|
||||
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
|
||||
Self { _guard: guard }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ReadLease {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("ReadLease")
|
||||
}
|
||||
}
|
||||
|
||||
/// A RAII **write** lease: holding it guarantees exclusive write access to the
|
||||
/// resource; dropping it releases the writer slot. At most one write lease exists
|
||||
/// for a resource at a time, and it excludes all readers.
|
||||
#[must_use = "a write lease releases the guard as soon as it is dropped"]
|
||||
pub struct WriteLease {
|
||||
_guard: Box<dyn Send + Sync>,
|
||||
}
|
||||
|
||||
impl WriteLease {
|
||||
/// Wraps an adapter-provided release guard into a domain [`WriteLease`].
|
||||
#[must_use]
|
||||
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
|
||||
Self { _guard: guard }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WriteLease {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("WriteLease")
|
||||
}
|
||||
}
|
||||
|
||||
/// A reader/writer guard over the bounded set of IdeA-owned files
|
||||
/// ([`GuardedResource`]).
|
||||
///
|
||||
/// Contract:
|
||||
/// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one
|
||||
/// resource concurrently.
|
||||
/// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other
|
||||
/// readers and writers of the **same** resource (different resources are
|
||||
/// independent).
|
||||
/// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a
|
||||
/// `who` that is not the orchestrator returns [`GuardError::Forbidden`].
|
||||
///
|
||||
/// Object-safe (held as `Arc<dyn FileGuard>`); blocking acquires serialize, so the
|
||||
/// only error is `Forbidden` (the cooperative path never spuriously fails a wait).
|
||||
#[async_trait]
|
||||
pub trait FileGuard: Send + Sync {
|
||||
/// Acquires a shared **read** lease on `res` for `who`. Serializes behind any
|
||||
/// in-flight writer of the same resource, then returns a [`ReadLease`].
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns
|
||||
/// `Ok` once the writer (if any) releases.
|
||||
async fn acquire_read(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<ReadLease, GuardError>;
|
||||
|
||||
/// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind
|
||||
/// any in-flight readers/writer of the same resource.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the
|
||||
/// single-writer [`GuardedResource::ProjectContext`].
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError>;
|
||||
}
|
||||
|
||||
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
|
||||
///
|
||||
/// Pure policy, shared by the port's documented contract and the adapter: only the
|
||||
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
|
||||
/// may write the per-agent context and memory. The orchestrator is modelled as
|
||||
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
|
||||
/// human-driven orchestrator), never a project [`AgentId`].
|
||||
#[must_use]
|
||||
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
|
||||
if res.is_project_context() {
|
||||
is_orchestrator(who)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `who` is the orchestrator identity for the single-writer rule.
|
||||
///
|
||||
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
|
||||
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
|
||||
/// propose changes to the global context.
|
||||
#[must_use]
|
||||
pub const fn is_orchestrator(who: ConversationParty) -> bool {
|
||||
matches!(who, ConversationParty::User)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn agent_party(n: u128) -> ConversationParty {
|
||||
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_is_single_writer_to_orchestrator_only() {
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::User,
|
||||
&GuardedResource::ProjectContext
|
||||
));
|
||||
assert!(!may_write_directly(
|
||||
agent_party(1),
|
||||
&GuardedResource::ProjectContext
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_context_and_memory_are_writable_by_anyone() {
|
||||
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
|
||||
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
|
||||
for who in [ConversationParty::User, agent_party(1)] {
|
||||
assert!(may_write_directly(who, &mem));
|
||||
assert!(may_write_directly(who, &ctx));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_orchestrator_only_for_user() {
|
||||
assert!(is_orchestrator(ConversationParty::User));
|
||||
assert!(!is_orchestrator(agent_party(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guarded_resource_equality_keys_on_identity() {
|
||||
let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
|
||||
let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
|
||||
let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2)));
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
assert_ne!(
|
||||
GuardedResource::ProjectContext,
|
||||
GuardedResource::Memory(MemorySlug::new("x").unwrap())
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user