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:
819
crates/application/src/orchestrator/context_guard.rs
Normal file
819
crates/application/src/orchestrator/context_guard.rs
Normal file
@ -0,0 +1,819 @@
|
||||
//! FileGuard-mediated context & memory use cases (cadrage C7).
|
||||
//!
|
||||
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
|
||||
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
|
||||
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
|
||||
//! the right lease (shared read / exclusive write) for the requesting
|
||||
//! [`ConversationParty`], then delegates to the existing store ports.
|
||||
//!
|
||||
//! ## Single-writer global context
|
||||
//!
|
||||
//! The global project context is single-writer: only the orchestrator
|
||||
//! ([`ConversationParty::User`]) may write it directly. A project agent that
|
||||
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
|
||||
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
|
||||
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
|
||||
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
|
||||
//! directly under a write-lease.
|
||||
//!
|
||||
//! ## Cooperative scope (cadrage §9.5)
|
||||
//!
|
||||
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
|
||||
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
|
||||
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
|
||||
//! scope here.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
|
||||
use domain::ports::{
|
||||
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
|
||||
};
|
||||
use domain::{AgentId, Project};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// Convention filename of the project's global context at the project root.
|
||||
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
|
||||
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
|
||||
const PROPOSALS_DIR: &str = ".ideai/proposals";
|
||||
|
||||
/// Joins a project root with a POSIX-relative segment (valid on every target).
|
||||
fn join_root(project: &Project, rel: &str) -> RemotePath {
|
||||
let base = project.root.as_str().trim_end_matches(['/', '\\']);
|
||||
RemotePath::new(format!("{base}/{rel}"))
|
||||
}
|
||||
|
||||
/// Resolves an agent display name to its [`AgentId`] via the project manifest
|
||||
/// (case-insensitive), or [`AppError::NotFound`].
|
||||
async fn resolve_agent(
|
||||
contexts: &Arc<dyn AgentContextStore>,
|
||||
project: &Project,
|
||||
name: &str,
|
||||
) -> Result<AgentId, AppError> {
|
||||
let manifest = contexts.load_manifest(project).await?;
|
||||
manifest
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|e| e.name.eq_ignore_ascii_case(name))
|
||||
.map(|e| e.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
|
||||
}
|
||||
|
||||
/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
|
||||
///
|
||||
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
|
||||
pub struct ReadContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
/// Input for [`ReadContext`].
|
||||
pub struct ReadContextInput {
|
||||
/// The project to read within.
|
||||
pub project: Project,
|
||||
/// Target agent display name; `None` ⇒ the global project context.
|
||||
pub target: Option<String>,
|
||||
/// The reading party (drives the read-lease holder identity).
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ReadContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs }
|
||||
}
|
||||
|
||||
/// Reads the requested context, returning its Markdown body.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
|
||||
let ReadContextInput { project, target, requester } = input;
|
||||
match target {
|
||||
None => {
|
||||
// Global project context: shared read-lease, then read the root file.
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
let bytes = self.fs.read(&path).await?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
Ok(MarkdownDoc::new(text))
|
||||
}
|
||||
Some(name) => {
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
Ok(self.contexts.read_context(&project, &agent).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
|
||||
///
|
||||
/// For an **agent** context: a direct write under an exclusive write-lease. For the
|
||||
/// **global** project context by a non-orchestrator: the guard returns
|
||||
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
|
||||
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
|
||||
pub struct ProposeContext {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
}
|
||||
|
||||
/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProposeOutcome {
|
||||
/// The content was written directly (agent context, or global by the orchestrator).
|
||||
Written,
|
||||
/// A proposal was materialised at the given `.ideai/proposals/…` path (a
|
||||
/// non-orchestrator targeting the single-writer global context).
|
||||
Proposed {
|
||||
/// The proposal file's path.
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Input for [`ProposeContext`].
|
||||
pub struct ProposeContextInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// Target agent display name; `None` ⇒ the global project context.
|
||||
pub target: Option<String>,
|
||||
/// The proposed Markdown body.
|
||||
pub content: String,
|
||||
/// The proposing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ProposeContext {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
guard: Arc<dyn FileGuard>,
|
||||
contexts: Arc<dyn AgentContextStore>,
|
||||
fs: Arc<dyn FileSystem>,
|
||||
clock: Arc<dyn Clock>,
|
||||
) -> Self {
|
||||
Self { guard, contexts, fs, clock }
|
||||
}
|
||||
|
||||
/// Applies the proposal: direct write under a write-lease, or a materialised
|
||||
/// proposal when the guard forbids a direct global-context write.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the agent does not exist or the store/fs fails.
|
||||
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
|
||||
let ProposeContextInput { project, target, content, requester } = input;
|
||||
match target {
|
||||
Some(name) => {
|
||||
// Per-agent context: direct write under an exclusive write-lease.
|
||||
let agent = resolve_agent(&self.contexts, &project, &name).await?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::AgentContext(agent))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
self.contexts
|
||||
.write_context(&project, &agent, &MarkdownDoc::new(content))
|
||||
.await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
None => {
|
||||
// Global project context: single-writer. Try to acquire the write
|
||||
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
|
||||
match self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::ProjectContext)
|
||||
.await
|
||||
{
|
||||
Ok(_lease) => {
|
||||
let path = join_root(&project, PROJECT_CONTEXT_FILE);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(ProposeOutcome::Written)
|
||||
}
|
||||
Err(GuardError::Forbidden) => {
|
||||
let path = self.file_proposal(&project, requester, &content).await?;
|
||||
Ok(ProposeOutcome::Proposed { path })
|
||||
}
|
||||
Err(other) => Err(map_guard_err(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Materialises a rejected global-context change as a proposal file under
|
||||
/// `.ideai/proposals/<who>-<ts>.md`, returning its path.
|
||||
async fn file_proposal(
|
||||
&self,
|
||||
project: &Project,
|
||||
who: ConversationParty,
|
||||
content: &str,
|
||||
) -> Result<String, AppError> {
|
||||
let who_label = match who {
|
||||
ConversationParty::User => "orchestrator".to_owned(),
|
||||
ConversationParty::Agent { agent_id } => agent_id.to_string(),
|
||||
};
|
||||
let ts = self.clock.now_millis();
|
||||
let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
|
||||
let dir = join_root(project, PROPOSALS_DIR);
|
||||
self.fs.create_dir_all(&dir).await?;
|
||||
let path = join_root(project, &rel);
|
||||
self.fs.write(&path, content.as_bytes()).await?;
|
||||
Ok(path.as_str().to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads project memory under a shared read-lease.
|
||||
///
|
||||
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
|
||||
pub struct ReadMemory {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
memory: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
/// Input for [`ReadMemory`].
|
||||
pub struct ReadMemoryInput {
|
||||
/// The project to read within.
|
||||
pub project: Project,
|
||||
/// Target note slug; `None` ⇒ the aggregated index.
|
||||
pub slug: Option<String>,
|
||||
/// The reading party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl ReadMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { guard, memory }
|
||||
}
|
||||
|
||||
/// Reads the requested memory, returning its Markdown content.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
|
||||
/// is [`AppError::Invalid`].
|
||||
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
|
||||
let ReadMemoryInput { project, slug, requester } = input;
|
||||
match slug {
|
||||
Some(raw) => {
|
||||
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_read(requester, GuardedResource::Memory(slug.clone()))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let note = self.memory.get(&project.root, &slug).await?;
|
||||
Ok(note.body.into_string())
|
||||
}
|
||||
None => {
|
||||
// The aggregated index is project-shared; read it as a rendered list.
|
||||
let entries = self.memory.read_index(&project.root).await?;
|
||||
let lines: Vec<String> = entries
|
||||
.into_iter()
|
||||
.map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
|
||||
.collect();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
|
||||
pub struct WriteMemory {
|
||||
guard: Arc<dyn FileGuard>,
|
||||
memory: Arc<dyn MemoryStore>,
|
||||
}
|
||||
|
||||
/// Input for [`WriteMemory`].
|
||||
pub struct WriteMemoryInput {
|
||||
/// The project to write within.
|
||||
pub project: Project,
|
||||
/// Target note slug.
|
||||
pub slug: String,
|
||||
/// The Markdown body to store.
|
||||
pub content: String,
|
||||
/// The writing party.
|
||||
pub requester: ConversationParty,
|
||||
}
|
||||
|
||||
impl WriteMemory {
|
||||
/// Builds the use case from its ports.
|
||||
#[must_use]
|
||||
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
|
||||
Self { guard, memory }
|
||||
}
|
||||
|
||||
/// Writes the note under a write-lease.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
|
||||
/// failure.
|
||||
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
|
||||
let WriteMemoryInput { project, slug, content, requester } = input;
|
||||
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let _lease = self
|
||||
.guard
|
||||
.acquire_write(requester, GuardedResource::Memory(slug.clone()))
|
||||
.await
|
||||
.map_err(map_guard_err)?;
|
||||
let frontmatter = MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: format!("memory note {slug}"),
|
||||
r#type: MemoryType::Project,
|
||||
};
|
||||
let note = Memory::new(frontmatter, MarkdownDoc::new(content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memory.save(&project.root, ¬e).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
|
||||
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
|
||||
/// blocking adapter never returns, but is mapped for completeness.
|
||||
fn map_guard_err(e: GuardError) -> AppError {
|
||||
match e {
|
||||
GuardError::Forbidden => AppError::Invalid(
|
||||
"writing the global project context is reserved to the orchestrator; propose instead"
|
||||
.to_owned(),
|
||||
),
|
||||
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{AgentManifest, ManifestEntry};
|
||||
use domain::conversation::ConversationParty;
|
||||
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
|
||||
use domain::ports::{FsError, MemoryError, StoreError};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::{ProfileId, ProjectId, RemoteRef};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc as StdArc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
|
||||
"demo",
|
||||
ProjectPath::new("/tmp/demo").unwrap(),
|
||||
RemoteRef::local(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
|
||||
/// `RwLock` per resource + the single-writer rule), so the application crate
|
||||
/// stays free of any dependency on infrastructure (no dependency cycle).
|
||||
#[derive(Default)]
|
||||
struct TestGuard {
|
||||
locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
|
||||
}
|
||||
|
||||
impl TestGuard {
|
||||
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
|
||||
self.locks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(res.clone())
|
||||
.or_insert_with(|| StdArc::new(RwLock::new(())))
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileGuard for TestGuard {
|
||||
async fn acquire_read(
|
||||
&self,
|
||||
_who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<ReadLease, GuardError> {
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(ReadLease::new(Box::new(lock.read_owned().await)))
|
||||
}
|
||||
async fn acquire_write(
|
||||
&self,
|
||||
who: ConversationParty,
|
||||
res: GuardedResource,
|
||||
) -> Result<WriteLease, GuardError> {
|
||||
if !may_write_directly(who, &res) {
|
||||
return Err(GuardError::Forbidden);
|
||||
}
|
||||
let lock = self.lock_for(&res);
|
||||
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_party(n: u128) -> ConversationParty {
|
||||
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
|
||||
}
|
||||
|
||||
// ---- Fakes -----------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFs {
|
||||
files: Mutex<HashMap<String, Vec<u8>>>,
|
||||
dirs: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
self.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path.as_str().to_owned(), data.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
|
||||
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
|
||||
}
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.dirs.lock().unwrap().push(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeContexts {
|
||||
manifest: AgentManifest,
|
||||
contexts: Mutex<HashMap<AgentId, String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(agent)
|
||||
.cloned()
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
_project: &Project,
|
||||
agent: &AgentId,
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
self.contexts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(*agent, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
||||
Ok(self.manifest.clone())
|
||||
}
|
||||
async fn save_manifest(
|
||||
&self,
|
||||
_project: &Project,
|
||||
_manifest: &AgentManifest,
|
||||
) -> Result<(), StoreError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeMemory {
|
||||
notes: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MemoryStore for FakeMemory {
|
||||
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
slug: &MemorySlug,
|
||||
) -> Result<Memory, MemoryError> {
|
||||
let body = self
|
||||
.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(slug.as_str())
|
||||
.cloned()
|
||||
.ok_or(MemoryError::NotFound)?;
|
||||
Memory::new(
|
||||
MemoryFrontmatter {
|
||||
name: slug.clone(),
|
||||
description: "d".to_owned(),
|
||||
r#type: MemoryType::Project,
|
||||
},
|
||||
MarkdownDoc::new(body),
|
||||
)
|
||||
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
|
||||
}
|
||||
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
||||
self.notes
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<(), MemoryError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn read_index(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn resolve_links(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
_slug: &MemorySlug,
|
||||
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
impl Clock for FixedClock {
|
||||
fn now_millis(&self) -> i64 {
|
||||
42
|
||||
}
|
||||
}
|
||||
|
||||
fn guard() -> Arc<dyn FileGuard> {
|
||||
Arc::new(TestGuard::default())
|
||||
}
|
||||
|
||||
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
|
||||
let mut contexts = HashMap::new();
|
||||
contexts.insert(agent, body.to_owned());
|
||||
Arc::new(FakeContexts {
|
||||
manifest: AgentManifest {
|
||||
version: 1,
|
||||
entries: vec![ManifestEntry {
|
||||
agent_id: agent,
|
||||
name: name.to_owned(),
|
||||
md_path: "agents/x.md".to_owned(),
|
||||
profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
|
||||
template_id: None,
|
||||
synchronized: false,
|
||||
synced_template_version: None,
|
||||
skills: Vec::new(),
|
||||
}],
|
||||
},
|
||||
contexts: Mutex::new(contexts),
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Tests -----------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agent_context_returns_body() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let uc = ReadContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", agent, "# hello"),
|
||||
Arc::new(FakeFs::default()),
|
||||
);
|
||||
let md = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: Some("dev".to_owned()), // case-insensitive
|
||||
requester: agent_party(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_global_context_reads_root_file() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
|
||||
let uc = ReadContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
fs,
|
||||
);
|
||||
let md = uc
|
||||
.execute(ReadContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
requester: ConversationParty::User,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(md.as_str(), "# project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_reads_do_not_block_each_other() {
|
||||
// Two readers on the same global context, held at once. If the read-lease
|
||||
// were exclusive this would deadlock; a bounded timeout proves it does not.
|
||||
let guard = guard();
|
||||
let r1 = guard
|
||||
.acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
|
||||
.await
|
||||
.unwrap();
|
||||
let r2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
|
||||
)
|
||||
.await
|
||||
.expect("a second reader must not block")
|
||||
.unwrap();
|
||||
drop((r1, r2));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
fs.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
content: "# hijack".to_owned(),
|
||||
requester: agent_party(3),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
// It is a *proposal*, not a write: the live context is untouched.
|
||||
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# original",
|
||||
"the live global context must NOT be overwritten by a proposal"
|
||||
);
|
||||
// The proposal landed under .ideai/proposals/.
|
||||
let files = fs.files.lock().unwrap();
|
||||
assert!(files
|
||||
.keys()
|
||||
.any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn orchestrator_writes_global_context_directly() {
|
||||
let fs = Arc::new(FakeFs::default());
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
|
||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: None,
|
||||
content: "# new".to_owned(),
|
||||
requester: ConversationParty::User,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, ProposeOutcome::Written);
|
||||
assert_eq!(
|
||||
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
|
||||
b"# new"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propose_agent_context_writes_directly() {
|
||||
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
|
||||
let contexts = contexts_with("Dev", agent, "# old");
|
||||
let uc = ProposeContext::new(
|
||||
guard(),
|
||||
Arc::clone(&contexts),
|
||||
Arc::new(FakeFs::default()),
|
||||
Arc::new(FixedClock),
|
||||
);
|
||||
let outcome = uc
|
||||
.execute(ProposeContextInput {
|
||||
project: project(),
|
||||
target: Some("Dev".to_owned()),
|
||||
content: "# new body".to_owned(),
|
||||
requester: agent_party(3),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outcome, ProposeOutcome::Written);
|
||||
assert_eq!(
|
||||
contexts
|
||||
.read_context(&project(), &agent)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_str(),
|
||||
"# new body"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_read_memory_round_trips_under_guard() {
|
||||
let memory = Arc::new(FakeMemory::default());
|
||||
let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
|
||||
writer
|
||||
.execute(WriteMemoryInput {
|
||||
project: project(),
|
||||
slug: "note-a".to_owned(),
|
||||
content: "body".to_owned(),
|
||||
requester: agent_party(2),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
|
||||
let body = reader
|
||||
.execute(ReadMemoryInput {
|
||||
project: project(),
|
||||
slug: Some("note-a".to_owned()),
|
||||
requester: agent_party(2),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(body, "body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_to_same_memory_note_serialise() {
|
||||
// Two write leases on the same note must not overlap (exclusive writer).
|
||||
let guard = guard();
|
||||
let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
|
||||
let w1 = guard
|
||||
.acquire_write(agent_party(1), slug.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
// While w1 is held, a second writer blocks; it only succeeds after release.
|
||||
let blocked = tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
guard.acquire_write(agent_party(2), slug.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "a second writer must block while w1 holds");
|
||||
drop(w1);
|
||||
let w2 = tokio::time::timeout(
|
||||
Duration::from_millis(200),
|
||||
guard.acquire_write(agent_party(2), slug),
|
||||
)
|
||||
.await
|
||||
.expect("w2 acquires after w1 releases")
|
||||
.unwrap();
|
||||
drop(w2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user