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,230 @@
//! [`RwFileGuard`] — the [`FileGuard`] adapter (lot C6).
//!
//! A reader/writer lock **per [`GuardedResource`]**, backed by one
//! [`tokio::sync::RwLock`] per resource (created lazily and shared via `Arc`). N
//! concurrent readers **or** one exclusive writer per resource; different resources
//! are independent (their locks are distinct).
//!
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
//! **before** taking any lock: a `who` that is not the orchestrator
//! ([`domain::may_write_directly`] returning `false`) is rejected with
//! [`GuardError::Forbidden`] — it must *propose* instead.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard serialises access **inside the IdeA path** (the MCP tools / use cases).
//! It is **cooperative**: it does not sandbox an agent that keeps a raw shell — real
//! airtightness (revoking raw fs access to these paths) is an OS-sandbox concern
//! (Landlock) and is **out of scope** here.
//!
//! ## Concurrency
//!
//! The resource → lock registry lives behind a **synchronous** [`Mutex`], held only
//! for the O(1) get-or-insert of an `Arc<RwLock<()>>` and **never across an
//! `.await`**. The actual wait happens on the tokio `RwLock`'s `owned` guard, whose
//! lifetime is `'static` (it owns its `Arc`), so it can be boxed into the domain's
//! RAII [`ReadLease`]/[`WriteLease`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::RwLock;
use domain::conversation::ConversationParty;
use domain::fileguard::{
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
};
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
///
/// Held at the composition root as `Arc<dyn FileGuard>`; cloneable bookkeeping is
/// interior (the registry is a shared `Mutex<HashMap<…>>`).
#[derive(Debug, Default)]
pub struct RwFileGuard {
/// Lazily-created per-resource locks. The unit `()` payload is irrelevant — the
/// lock's *mode* (read vs write) is the whole point; the lease only proves the
/// slot is held until drop.
locks: Mutex<HashMap<GuardedResource, Arc<RwLock<()>>>>,
}
impl RwFileGuard {
/// Builds an empty guard (no resource is contended until first acquired).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns the shared lock for `res`, creating it on first use. The registry
/// mutex is held only for this O(1) get-or-insert, never across an `.await`.
fn lock_for(&self, res: &GuardedResource) -> Arc<RwLock<()>> {
let mut registry = self
.locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registry
.entry(res.clone())
.or_insert_with(|| Arc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for RwFileGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
// Reading is never forbidden (cooperative contract): serialise behind any
// in-flight writer of the same resource, then hand back the RAII lease.
let lock = self.lock_for(&res);
let guard = lock.read_owned().await;
Ok(ReadLease::new(Box::new(guard)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
// Single-writer rule for the global project context: refuse *before* taking
// any lock so a forbidden writer never blocks readers.
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
let guard = lock.write_owned().await;
Ok(WriteLease::new(Box::new(guard)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
use domain::memory::MemorySlug;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
fn mem(slug: &str) -> GuardedResource {
GuardedResource::Memory(MemorySlug::new(slug).unwrap())
}
#[tokio::test]
async fn many_readers_share_one_resource_concurrently() {
let guard = RwFileGuard::new();
// Three read leases held *at once* on the same resource must all be granted.
let a = guard
.acquire_read(ConversationParty::User, mem("note"))
.await
.unwrap();
let b = guard
.acquire_read(agent_party(1), mem("note"))
.await
.unwrap();
let c = guard
.acquire_read(agent_party(2), mem("note"))
.await
.unwrap();
// If reads were exclusive this would have deadlocked above; reaching here is
// the proof. Keep the leases alive until now.
drop((a, b, c));
}
#[tokio::test]
async fn writer_excludes_other_writers_serialising_them() {
let guard = Arc::new(RwFileGuard::new());
let counter = Arc::new(AtomicUsize::new(0));
let observed_max = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let guard = Arc::clone(&guard);
let counter = Arc::clone(&counter);
let observed_max = Arc::clone(&observed_max);
handles.push(tokio::spawn(async move {
let _lease = guard
.acquire_write(ConversationParty::User, mem("shared"))
.await
.unwrap();
// While holding the write lease, at most one task may be inside.
let inside = counter.fetch_add(1, Ordering::SeqCst) + 1;
observed_max.fetch_max(inside, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
counter.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
observed_max.load(Ordering::SeqCst),
1,
"a write lease must be exclusive — never two writers inside at once"
);
}
#[tokio::test]
async fn agent_writing_project_context_is_forbidden() {
let guard = RwFileGuard::new();
let err = guard
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
.await
.unwrap_err();
assert_eq!(err, GuardError::Forbidden);
}
#[tokio::test]
async fn orchestrator_may_write_project_context() {
let guard = RwFileGuard::new();
let lease = guard
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
.await;
assert!(lease.is_ok());
}
#[tokio::test]
async fn write_lease_releases_on_drop_letting_the_next_writer_in() {
let guard = Arc::new(RwFileGuard::new());
{
let _lease = guard
.acquire_write(ConversationParty::User, mem("r"))
.await
.unwrap();
// Held here; a concurrent writer would block.
} // <- RAII release at end of scope.
// After the scope, a fresh writer acquires immediately (bounded wait).
let again = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("r")),
)
.await
.expect("lease must have released on drop")
.unwrap();
drop(again);
}
#[tokio::test]
async fn distinct_resources_do_not_block_each_other() {
let guard = RwFileGuard::new();
let _w1 = guard
.acquire_write(ConversationParty::User, mem("alpha"))
.await
.unwrap();
// A writer on a *different* resource is independent — must not block.
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("beta")),
)
.await
.expect("independent resources must not contend")
.unwrap();
drop(w2);
}
}