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:
230
crates/infrastructure/src/fileguard/mod.rs
Normal file
230
crates/infrastructure/src/fileguard/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user