Introduit le modèle AgentManifest { version, entries, orchestrator } et la
garde d'écriture directe may_write_directly(..., &OrchestratorDesignation) :
seul l'orchestrateur désigné peut écrire directement, les autres passent par
le rendez-vous médié. Câble la désignation à travers domain → application →
infrastructure → app-tauri (context_guard, service, lifecycle, ports).
Ajoute crates/application/src/diag.rs : sink de diagnostic best-effort, sans
dépendance, qui miroite les traces du rendez-vous inter-agents de
l'orchestrateur vers un fichier de log persistant (utile au lancement via
AppImage où stderr est jeté), avec la même discipline « zéro dépendance,
ne casse jamais le rendez-vous ».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
324 lines
12 KiB
Rust
324 lines
12 KiB
Rust
//! 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>;
|
|
}
|
|
|
|
/// **Which agent (if any) the project designates as its orchestrator.**
|
|
///
|
|
/// We persist only the *deviation* from the default (cadrage T1, « orchestrateur du
|
|
/// projet ») : the human ([`ConversationParty::User`]) is a **permanent** orchestrator
|
|
/// and is never represented here.
|
|
/// - [`none`](Self::none) — no agent designated: the human is the sole orchestrator.
|
|
/// - [`of`](Self::of) — agent `id` is the explicitly designated orchestrator (radio
|
|
/// selection, single agent by construction — the illegal "two orchestrators" state
|
|
/// is unrepresentable).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OrchestratorDesignation(Option<AgentId>);
|
|
|
|
impl OrchestratorDesignation {
|
|
/// No agent designated: only the human ([`ConversationParty::User`]) orchestrates.
|
|
#[must_use]
|
|
pub const fn none() -> Self {
|
|
Self(None)
|
|
}
|
|
|
|
/// Designates `agent` as the project's orchestrator (radio selection).
|
|
#[must_use]
|
|
pub const fn of(agent: AgentId) -> Self {
|
|
Self(Some(agent))
|
|
}
|
|
|
|
/// The designated agent, if any. `None` ⇒ default (human-only orchestrator).
|
|
#[must_use]
|
|
pub const fn designated(&self) -> Option<AgentId> {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
/// 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 an
|
|
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
|
|
/// may write the per-agent context and memory. Orchestrator identity is resolved
|
|
/// against the project's [`OrchestratorDesignation`] (the human is always one; a
|
|
/// project agent only when explicitly designated).
|
|
#[must_use]
|
|
pub fn may_write_directly(
|
|
who: ConversationParty,
|
|
res: &GuardedResource,
|
|
d: &OrchestratorDesignation,
|
|
) -> bool {
|
|
if res.is_project_context() {
|
|
is_orchestrator(who, d)
|
|
} else {
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Whether `who` is an orchestrator identity for the single-writer rule, given the
|
|
/// project's [`OrchestratorDesignation`] `d`.
|
|
///
|
|
/// The human-driven operator ([`ConversationParty::User`]) is **always** an
|
|
/// orchestrator. A project agent ([`ConversationParty::Agent`]) is an orchestrator
|
|
/// **only** when it is the designated one; otherwise it must propose changes to the
|
|
/// global context.
|
|
#[must_use]
|
|
pub fn is_orchestrator(who: ConversationParty, d: &OrchestratorDesignation) -> bool {
|
|
match who {
|
|
ConversationParty::User => true,
|
|
ConversationParty::Agent { agent_id } => d.designated() == Some(agent_id),
|
|
}
|
|
}
|
|
|
|
#[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() {
|
|
// Single-writer rule preserved with the default (no designated agent):
|
|
// the human writes, every agent is refused.
|
|
let default = OrchestratorDesignation::none();
|
|
assert!(may_write_directly(
|
|
ConversationParty::User,
|
|
&GuardedResource::ProjectContext,
|
|
&default,
|
|
));
|
|
assert!(!may_write_directly(
|
|
agent_party(1),
|
|
&GuardedResource::ProjectContext,
|
|
&default,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn designated_agent_may_write_project_context() {
|
|
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
|
let designation = OrchestratorDesignation::of(id);
|
|
// The designated agent now writes the global context directly…
|
|
assert!(may_write_directly(
|
|
ConversationParty::agent(id),
|
|
&GuardedResource::ProjectContext,
|
|
&designation,
|
|
));
|
|
// …while another agent stays refused, and the human stays allowed.
|
|
assert!(!may_write_directly(
|
|
agent_party(2),
|
|
&GuardedResource::ProjectContext,
|
|
&designation,
|
|
));
|
|
assert!(may_write_directly(
|
|
ConversationParty::User,
|
|
&GuardedResource::ProjectContext,
|
|
&designation,
|
|
));
|
|
}
|
|
|
|
#[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)));
|
|
let default = OrchestratorDesignation::none();
|
|
for who in [ConversationParty::User, agent_party(1)] {
|
|
assert!(may_write_directly(who, &mem, &default));
|
|
assert!(may_write_directly(who, &ctx, &default));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn is_orchestrator_for_user_and_designated_agent() {
|
|
let id = AgentId::from_uuid(uuid::Uuid::from_u128(1));
|
|
// Human is always an orchestrator, designation or not.
|
|
assert!(is_orchestrator(
|
|
ConversationParty::User,
|
|
&OrchestratorDesignation::none()
|
|
));
|
|
// An agent is an orchestrator only when it is the designated one.
|
|
assert!(!is_orchestrator(
|
|
ConversationParty::agent(id),
|
|
&OrchestratorDesignation::none()
|
|
));
|
|
assert!(is_orchestrator(
|
|
ConversationParty::agent(id),
|
|
&OrchestratorDesignation::of(id)
|
|
));
|
|
assert!(!is_orchestrator(
|
|
agent_party(2),
|
|
&OrchestratorDesignation::of(id)
|
|
));
|
|
}
|
|
|
|
#[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())
|
|
);
|
|
}
|
|
}
|