feat(orchestrator): modèle de désignation d'orchestrateur + sink de diagnostic
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>
This commit is contained in:
@ -159,30 +159,71 @@ pub trait FileGuard: Send + Sync {
|
||||
) -> 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 the
|
||||
/// 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. The orchestrator is modelled as
|
||||
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
|
||||
/// human-driven orchestrator), never a project [`AgentId`].
|
||||
/// 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) -> bool {
|
||||
pub fn may_write_directly(
|
||||
who: ConversationParty,
|
||||
res: &GuardedResource,
|
||||
d: &OrchestratorDesignation,
|
||||
) -> bool {
|
||||
if res.is_project_context() {
|
||||
is_orchestrator(who)
|
||||
is_orchestrator(who, d)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `who` is the orchestrator identity for the single-writer rule.
|
||||
/// Whether `who` is an orchestrator identity for the single-writer rule, given the
|
||||
/// project's [`OrchestratorDesignation`] `d`.
|
||||
///
|
||||
/// 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.
|
||||
/// 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 const fn is_orchestrator(who: ConversationParty) -> bool {
|
||||
matches!(who, ConversationParty::User)
|
||||
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)]
|
||||
@ -195,13 +236,41 @@ mod tests {
|
||||
|
||||
#[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
|
||||
&GuardedResource::ProjectContext,
|
||||
&default,
|
||||
));
|
||||
assert!(!may_write_directly(
|
||||
agent_party(1),
|
||||
&GuardedResource::ProjectContext
|
||||
&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,
|
||||
));
|
||||
}
|
||||
|
||||
@ -209,16 +278,34 @@ mod tests {
|
||||
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));
|
||||
assert!(may_write_directly(who, &ctx));
|
||||
assert!(may_write_directly(who, &mem, &default));
|
||||
assert!(may_write_directly(who, &ctx, &default));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_orchestrator_only_for_user() {
|
||||
assert!(is_orchestrator(ConversationParty::User));
|
||||
assert!(!is_orchestrator(agent_party(1)));
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user