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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

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