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:
@ -277,24 +277,59 @@ impl ManifestEntry {
|
||||
|
||||
/// In-memory image of `.ideai/agents.json`.
|
||||
///
|
||||
/// Invariant enforced here: `md_path` values are unique across entries.
|
||||
/// Invariants enforced here:
|
||||
/// - `md_path` values are unique across entries;
|
||||
/// - `orchestrator == Some(id)` ⇒ `id` is present in `entries` (referential).
|
||||
///
|
||||
/// **Ordering invariant**: `entries` are kept in **creation order**, so
|
||||
/// `entries.first()` is the **oldest** agent. There is no per-entry timestamp; the
|
||||
/// vector position *is* the chronology, which is why the default orchestrator (when
|
||||
/// none is designated) is `entries.first()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentManifest {
|
||||
/// Schema version of the manifest file.
|
||||
pub version: u32,
|
||||
/// Entries (one per project agent).
|
||||
/// Entries (one per project agent), in **creation order** (oldest first).
|
||||
#[serde(rename = "agents")]
|
||||
pub entries: Vec<ManifestEntry>,
|
||||
/// Explicitly designated orchestrator agent, if any.
|
||||
///
|
||||
/// We persist only the **deviation** from the default: `None` (the common case)
|
||||
/// means « the oldest agent orchestrates » (see [`effective_orchestrator`]);
|
||||
/// `Some(id)` is an explicit designation. Skipped on serialisation when `None`,
|
||||
/// so pre-feature manifests round-trip unchanged (free backward compatibility).
|
||||
///
|
||||
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub orchestrator: Option<AgentId>,
|
||||
}
|
||||
|
||||
impl AgentManifest {
|
||||
/// Builds a validated manifest.
|
||||
/// Builds a validated manifest with **no** explicit orchestrator (the default:
|
||||
/// the oldest agent orchestrates — see [`effective_orchestrator`]).
|
||||
///
|
||||
/// [`effective_orchestrator`]: AgentManifest::effective_orchestrator
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`DomainError::InconsistentManifest`] if two entries share the
|
||||
/// same `md_path`.
|
||||
pub fn new(version: u32, entries: Vec<ManifestEntry>) -> Result<Self, DomainError> {
|
||||
Self::with_orchestrator(version, entries, None)
|
||||
}
|
||||
|
||||
/// Builds a validated manifest carrying an explicit `orchestrator` designation.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::InconsistentManifest`] if two entries share the same
|
||||
/// `md_path`;
|
||||
/// - [`DomainError::InconsistentManifest`] if `orchestrator == Some(id)` while
|
||||
/// `id` is **not** present in `entries` (referential integrity).
|
||||
pub fn with_orchestrator(
|
||||
version: u32,
|
||||
entries: Vec<ManifestEntry>,
|
||||
orchestrator: Option<AgentId>,
|
||||
) -> Result<Self, DomainError> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for entry in &entries {
|
||||
if !seen.insert(entry.md_path.as_str()) {
|
||||
@ -303,6 +338,196 @@ impl AgentManifest {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self { version, entries })
|
||||
if let Some(id) = orchestrator {
|
||||
if !entries.iter().any(|e| e.agent_id == id) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: format!("designated orchestrator `{id}` is not a known agent"),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
version,
|
||||
entries,
|
||||
orchestrator,
|
||||
})
|
||||
}
|
||||
|
||||
/// The **effective** orchestrator agent: the explicit designation if any,
|
||||
/// otherwise the **oldest** agent (`entries.first()`, by the creation-order
|
||||
/// invariant). `None` only when the manifest has no entries at all.
|
||||
#[must_use]
|
||||
pub fn effective_orchestrator(&self) -> Option<AgentId> {
|
||||
self.orchestrator
|
||||
.or_else(|| self.entries.first().map(|e| e.agent_id))
|
||||
}
|
||||
|
||||
/// Folds the [`effective_orchestrator`](Self::effective_orchestrator) into the
|
||||
/// [`OrchestratorDesignation`] value object consumed by the FileGuard policy.
|
||||
#[must_use]
|
||||
pub fn orchestrator_designation(&self) -> crate::fileguard::OrchestratorDesignation {
|
||||
match self.effective_orchestrator() {
|
||||
Some(id) => crate::fileguard::OrchestratorDesignation::of(id),
|
||||
None => crate::fileguard::OrchestratorDesignation::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Designates `id` as the project's orchestrator (radio semantics: overwrites any
|
||||
/// previous designation).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DomainError::InconsistentManifest`] if `id` is not a known agent of this
|
||||
/// manifest.
|
||||
pub fn designate(&mut self, id: AgentId) -> Result<(), DomainError> {
|
||||
if !self.entries.iter().any(|e| e.agent_id == id) {
|
||||
return Err(DomainError::InconsistentManifest {
|
||||
reason: format!("cannot designate unknown agent `{id}` as orchestrator"),
|
||||
});
|
||||
}
|
||||
self.orchestrator = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reacts to the removal of an agent: if it was the **explicitly designated**
|
||||
/// orchestrator, clears the designation so the manifest falls back to the default
|
||||
/// (lazy succession — the next-oldest agent becomes effective). A no-op when the
|
||||
/// removed agent was not the designated one.
|
||||
///
|
||||
/// Pure bookkeeping: it does **not** remove the entry itself (the caller owns
|
||||
/// `entries`); it only keeps the `orchestrator` deviation consistent.
|
||||
pub fn on_agent_deleted(&mut self, removed: AgentId) {
|
||||
if self.orchestrator == Some(removed) {
|
||||
self.orchestrator = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod orchestrator_tests {
|
||||
use super::*;
|
||||
use crate::conversation::ConversationParty;
|
||||
use crate::fileguard::{may_write_directly, GuardedResource, OrchestratorDesignation};
|
||||
|
||||
fn agent_id(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(uuid::Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
/// Builds a manifest entry for `id`, with a `md_path` derived from `n` so entries
|
||||
/// stay unique.
|
||||
fn entry(n: u128) -> ManifestEntry {
|
||||
ManifestEntry::new(
|
||||
agent_id(n),
|
||||
format!("agent-{n}"),
|
||||
format!("agents/agent-{n}.md"),
|
||||
ProfileId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_orchestrator_is_oldest_agent_when_none_designated() {
|
||||
// entries are in creation order: first is the oldest → the effective default.
|
||||
let m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||
assert_eq!(m.orchestrator, None);
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||
assert_eq!(
|
||||
m.orchestrator_designation(),
|
||||
OrchestratorDesignation::of(agent_id(1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_manifest_has_no_effective_orchestrator() {
|
||||
let m = AgentManifest::new(1, vec![]).unwrap();
|
||||
assert_eq!(m.effective_orchestrator(), None);
|
||||
assert_eq!(
|
||||
m.orchestrator_designation(),
|
||||
OrchestratorDesignation::none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_designation_overrides_oldest() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designate_is_radio_and_rejects_unknown_agent() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(1)).unwrap();
|
||||
// Radio: a second designation overwrites the first (never accumulates).
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
// Referential: designating an agent absent from `entries` is rejected.
|
||||
let err = m.designate(agent_id(99)).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
// …and the previous valid designation is untouched.
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_succession_falls_back_to_oldest_when_designated_is_deleted() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2), entry(3)]).unwrap();
|
||||
m.designate(agent_id(3)).unwrap();
|
||||
// The designated agent is removed → designation cleared, default takes over.
|
||||
m.entries.retain(|e| e.agent_id != agent_id(3));
|
||||
m.on_agent_deleted(agent_id(3));
|
||||
assert_eq!(m.orchestrator, None);
|
||||
// Falls back to the (now) oldest remaining agent.
|
||||
assert_eq!(m.effective_orchestrator(), Some(agent_id(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_agent_deleted_is_noop_for_non_designated_agent() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(2)).unwrap();
|
||||
m.on_agent_deleted(agent_id(1));
|
||||
// Deleting a non-designated agent leaves the designation intact.
|
||||
assert_eq!(m.orchestrator, Some(agent_id(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_enforces_referential_integrity_of_orchestrator() {
|
||||
// Some(id) with id present in entries → ok.
|
||||
assert!(
|
||||
AgentManifest::with_orchestrator(1, vec![entry(1), entry(2)], Some(agent_id(2)),)
|
||||
.is_ok()
|
||||
);
|
||||
// Some(id) with id absent → rejected.
|
||||
let err =
|
||||
AgentManifest::with_orchestrator(1, vec![entry(1)], Some(agent_id(42))).unwrap_err();
|
||||
assert!(matches!(err, DomainError::InconsistentManifest { .. }));
|
||||
// None is always valid (even with an empty manifest).
|
||||
assert!(AgentManifest::with_orchestrator(1, vec![], None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn designated_agent_may_write_project_context_via_manifest_designation() {
|
||||
let mut m = AgentManifest::new(1, vec![entry(1), entry(2)]).unwrap();
|
||||
m.designate(agent_id(1)).unwrap();
|
||||
let d = m.orchestrator_designation();
|
||||
// The designated agent writes the project context directly…
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::agent(agent_id(1)),
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
// …another agent is refused (single-writer preserved)…
|
||||
assert!(!may_write_directly(
|
||||
ConversationParty::agent(agent_id(2)),
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
// …and the human is always allowed.
|
||||
assert!(may_write_directly(
|
||||
ConversationParty::User,
|
||||
&GuardedResource::ProjectContext,
|
||||
&d,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,6 +258,19 @@ pub enum DomainEvent {
|
||||
/// sinon `None` (l'utilisateur fournira l'heure).
|
||||
resets_at_ms: Option<i64>,
|
||||
},
|
||||
/// The project's **orchestrator designation** changed (cadrage « orchestrateur
|
||||
/// du projet », T1). Emitted when an agent is designated (radio selection) or the
|
||||
/// designation is cleared back to the default (e.g. the designated agent was
|
||||
/// deleted — lazy succession to the oldest agent). Relayed to the front so the UI
|
||||
/// can move the radio / refresh the orchestrator badge. Model-agnostic: carries
|
||||
/// only the neutral fact « who orchestrates now ».
|
||||
OrchestratorChanged {
|
||||
/// The project whose designation changed.
|
||||
project_id: ProjectId,
|
||||
/// The newly designated orchestrator agent, or `None` for the default
|
||||
/// (the oldest agent orchestrates).
|
||||
orchestrator: Option<AgentId>,
|
||||
},
|
||||
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
|
||||
PtyOutput {
|
||||
/// The session.
|
||||
@ -358,6 +371,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_changed_constructs_and_compares() {
|
||||
let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100));
|
||||
let ev = DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: Some(agent(1)),
|
||||
};
|
||||
assert_eq!(
|
||||
ev,
|
||||
DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: Some(agent(1)),
|
||||
}
|
||||
);
|
||||
// Clearing to the default (None) is a distinct event.
|
||||
assert_ne!(
|
||||
ev,
|
||||
DomainEvent::OrchestratorChanged {
|
||||
project_id: project,
|
||||
orchestrator: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_session_limit_variants_are_not_equal() {
|
||||
// Les variantes ne se confondent pas entre elles malgré des champs proches.
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -49,9 +49,9 @@ pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod readiness;
|
||||
pub mod remote;
|
||||
pub mod sandbox;
|
||||
pub mod session_limit;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
@ -93,7 +93,7 @@ pub use input::{AgentBusyState, AgentLiveness, InputMediator, InputSource};
|
||||
|
||||
pub use readiness::{ReadinessPolicy, ReadinessSignal};
|
||||
|
||||
pub use session_limit::{plan_resume, ResumePlan, RateLimitSource, SessionLimit};
|
||||
pub use session_limit::{plan_resume, RateLimitSource, ResumePlan, SessionLimit};
|
||||
|
||||
pub use conversation_log::{
|
||||
ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
@ -101,8 +101,8 @@ pub use conversation_log::{
|
||||
};
|
||||
|
||||
pub use fileguard::{
|
||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
|
||||
WriteLease,
|
||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
|
||||
OrchestratorDesignation, ReadLease, WriteLease,
|
||||
};
|
||||
|
||||
pub use markdown::MarkdownDoc;
|
||||
@ -126,7 +126,7 @@ pub use permission::{
|
||||
render_permission_summary, resolve as resolve_permissions, AgentPermissionOverride, Capability,
|
||||
CommandMatcher, CommandRule, Effect, EffectivePermissions, Glob, PathScope, PermissionError,
|
||||
PermissionProjection, PermissionProjector, PermissionRule, PermissionSet, Posture,
|
||||
ProjectedFile, ProjectionContext, ProjectPermissions, ProjectorKey, PERMISSIONS_VERSION,
|
||||
ProjectPermissions, ProjectedFile, ProjectionContext, ProjectorKey, PERMISSIONS_VERSION,
|
||||
};
|
||||
|
||||
pub use sandbox::{
|
||||
@ -145,6 +145,6 @@ pub use ports::{
|
||||
FsError, GitCommitInfo, GitError, GitFileStatus, GitPort, GraphCommit, IdGenerator,
|
||||
MemoryError, MemoryQuery, MemoryRecall, MemoryStore, Output, OutputStream, PermissionStore,
|
||||
PreparedContext, ProcessError, ProcessSpawner, ProfileStore, ProjectStore, PtyError, PtyHandle,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler, SpawnSpec,
|
||||
StoreError, TemplateStore,
|
||||
PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError, ScheduledTask, Scheduler,
|
||||
SpawnSpec, StoreError, TemplateStore,
|
||||
};
|
||||
|
||||
@ -894,8 +894,11 @@ pub trait PermissionProjector: Send + Sync {
|
||||
///
|
||||
/// `eff == None` ⇒ the **empty** projection ([`PermissionProjection::empty`]):
|
||||
/// we keep the CLI's native prompting (the product invariant of [`resolve`]).
|
||||
fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext)
|
||||
-> PermissionProjection;
|
||||
fn project(
|
||||
&self,
|
||||
eff: Option<&EffectivePermissions>,
|
||||
ctx: &ProjectionContext,
|
||||
) -> PermissionProjection;
|
||||
|
||||
/// Run-dir-relative paths of the [`ProjectedFile::Replace`] files this
|
||||
/// projector owns, so the swap path can clean them up when an agent moves
|
||||
@ -1448,7 +1451,10 @@ mod tests {
|
||||
let md = render_permission_summary(Some(&eff)).expect("a posed policy renders a summary");
|
||||
|
||||
// Files: OS-enforced / Landlock when supported.
|
||||
assert!(md.contains("OS-enforced"), "files block must say OS-enforced");
|
||||
assert!(
|
||||
md.contains("OS-enforced"),
|
||||
"files block must say OS-enforced"
|
||||
);
|
||||
assert!(md.contains("Landlock"), "files block must name Landlock");
|
||||
// Commands: advisory, NOT OS-locked, and the why (ExecuteBash).
|
||||
assert!(md.contains("advisory"), "commands must be called advisory");
|
||||
@ -1462,7 +1468,10 @@ mod tests {
|
||||
);
|
||||
// The actual rules surface in their respective sections.
|
||||
assert!(md.contains("`src/**`"), "the file scope is shown");
|
||||
assert!(md.contains("`rm *` (prefix)"), "the command matcher is shown");
|
||||
assert!(
|
||||
md.contains("`rm *` (prefix)"),
|
||||
"the command matcher is shown"
|
||||
);
|
||||
// Resolved posture is surfaced.
|
||||
assert!(md.contains("**Default posture:** Ask"));
|
||||
}
|
||||
|
||||
@ -112,6 +112,8 @@ pub struct PreparedContext {
|
||||
pub content: MarkdownDoc,
|
||||
/// Relative path of the `.md` inside the project.
|
||||
pub relative_path: String,
|
||||
/// Absolute root of the project this context belongs to.
|
||||
pub project_root: String,
|
||||
}
|
||||
|
||||
/// Enriched, **best-effort** details about a conversation, specific to a CLI's
|
||||
|
||||
@ -422,6 +422,12 @@ pub struct McpServerWiring {
|
||||
}
|
||||
|
||||
impl McpServerWiring {
|
||||
/// Codex's MCP client defaults tool calls to a short interactive timeout (120s on
|
||||
/// the versions observed in IdeA). `idea_ask_agent` is a synchronous rendezvous
|
||||
/// that can legitimately span a full delegated dev/test turn, so the generated
|
||||
/// server config must widen the per-tool timeout.
|
||||
pub const IDEA_TOOL_TIMEOUT_SEC: u32 = 24 * 60 * 60;
|
||||
|
||||
/// Construit le wiring depuis ses parties.
|
||||
#[must_use]
|
||||
pub const fn new(command: String, args: Vec<String>, transport: McpTransport) -> Self {
|
||||
@ -475,9 +481,9 @@ impl McpServerWiring {
|
||||
}
|
||||
|
||||
/// Encode la table **`[mcp_servers.idea]`** d'un `config.toml` Codex :
|
||||
/// `command`, `args` (tableau TOML), `transport`. Chaque chaîne est échappée en
|
||||
/// chaîne basique TOML (équivalent du `json_string` : espaces/backslash/quotes
|
||||
/// restent valides).
|
||||
/// `command`, `args` (tableau TOML), `transport`, et l'approbation automatique
|
||||
/// des outils IdeA. Chaque chaîne est échappée en chaîne basique TOML
|
||||
/// (équivalent du `json_string` : espaces/backslash/quotes restent valides).
|
||||
#[must_use]
|
||||
pub fn to_config_toml(&self) -> String {
|
||||
let command = toml_string(&self.command);
|
||||
@ -488,8 +494,9 @@ impl McpServerWiring {
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let transport = self.transport_label();
|
||||
let tool_timeout_sec = Self::IDEA_TOOL_TIMEOUT_SEC;
|
||||
format!(
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\n"
|
||||
"[mcp_servers.idea]\ncommand = {command}\nargs = [{args}]\ntransport = \"{transport}\"\ndefault_tools_approval_mode = \"approve\"\ntool_timeout_sec = {tool_timeout_sec}\n"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -910,11 +917,16 @@ impl AgentProfile {
|
||||
/// ([`crate`] côté application) et la matérialisation ne puissent pas diverger.
|
||||
#[must_use]
|
||||
pub fn materializes_idea_bridge(&self) -> bool {
|
||||
match (self.structured_adapter, self.mcp.as_ref().map(|c| &c.config)) {
|
||||
match (
|
||||
self.structured_adapter,
|
||||
self.mcp.as_ref().map(|c| &c.config),
|
||||
) {
|
||||
(Some(StructuredAdapter::Claude), Some(McpConfigStrategy::ConfigFile { target })) => {
|
||||
target == ".mcp.json"
|
||||
}
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => true,
|
||||
(Some(StructuredAdapter::Codex), Some(McpConfigStrategy::TomlConfigHome { .. })) => {
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@ -1262,7 +1274,10 @@ mod mcp_tests {
|
||||
!json.contains("stallAfterMs"),
|
||||
"an unset stall threshold must be omitted; got: {json}"
|
||||
);
|
||||
assert!(json.contains("turnTimeoutMs"), "set threshold present: {json}");
|
||||
assert!(
|
||||
json.contains("turnTimeoutMs"),
|
||||
"set threshold present: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1351,8 +1366,7 @@ mod mcp_tests {
|
||||
assert!(codex.materializes_idea_bridge());
|
||||
|
||||
// Codex WITHOUT any MCP capability ⇒ no bridge.
|
||||
let codex_no_mcp =
|
||||
profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
let codex_no_mcp = profile_without_mcp().with_structured_adapter(StructuredAdapter::Codex);
|
||||
assert!(!codex_no_mcp.materializes_idea_bridge());
|
||||
|
||||
// Codex + wrong strategy (`.mcp.json` ConfigFile, Claude's shape) ⇒ no bridge.
|
||||
@ -1448,6 +1462,16 @@ mod mcp_tests {
|
||||
toml.contains("transport = \"stdio\""),
|
||||
"transport expected; got: {toml}"
|
||||
);
|
||||
// IdeA-owned MCP tools are pre-approved, matching Claude's non-prompting
|
||||
// `.mcp.json` path.
|
||||
assert!(
|
||||
toml.contains("default_tools_approval_mode = \"approve\""),
|
||||
"IdeA MCP tools should not prompt for approval; got: {toml}"
|
||||
);
|
||||
assert!(
|
||||
toml.contains("tool_timeout_sec = 86400"),
|
||||
"IdeA MCP tools must outlive Codex's short default tool timeout; got: {toml}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- §21 : rate_limit_pattern (détection de limite par motif, niveau 2) ------
|
||||
@ -1519,8 +1543,14 @@ mod mcp_tests {
|
||||
let json = serde_json::to_string(&profile).expect("serialise");
|
||||
assert!(json.contains("rateLimitPattern"), "key present: {json}");
|
||||
// camelCase respecté sur les champs de RateLimitPattern.
|
||||
assert!(json.contains("resetCapture"), "camelCase field resetCapture: {json}");
|
||||
assert!(json.contains("timeFormat"), "camelCase field timeFormat: {json}");
|
||||
assert!(
|
||||
json.contains("resetCapture"),
|
||||
"camelCase field resetCapture: {json}"
|
||||
);
|
||||
assert!(
|
||||
json.contains("timeFormat"),
|
||||
"camelCase field timeFormat: {json}"
|
||||
);
|
||||
|
||||
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(profile, back);
|
||||
@ -1531,7 +1561,10 @@ mod mcp_tests {
|
||||
// reset_capture / time_format à None ⇒ leurs clés sont omises.
|
||||
let pattern = RateLimitPattern::new("rate limited", None, None).expect("valid pattern");
|
||||
let json = serde_json::to_string(&pattern).expect("serialise");
|
||||
assert!(json.contains("\"pattern\""), "pattern field present: {json}");
|
||||
assert!(
|
||||
json.contains("\"pattern\""),
|
||||
"pattern field present: {json}"
|
||||
);
|
||||
assert!(
|
||||
!json.contains("resetCapture"),
|
||||
"an unset resetCapture must be omitted; got: {json}"
|
||||
|
||||
@ -89,11 +89,9 @@ impl ReadinessPolicy {
|
||||
pub const fn classify(event: &ReplyEvent) -> Option<ReadinessSignal> {
|
||||
match event {
|
||||
ReplyEvent::Final { .. } => Some(ReadinessSignal::TurnEnded),
|
||||
ReplyEvent::RateLimited { resets_at_ms } => {
|
||||
Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
})
|
||||
}
|
||||
ReplyEvent::RateLimited { resets_at_ms } => Some(ReadinessSignal::RateLimited {
|
||||
resets_at_ms: *resets_at_ms,
|
||||
}),
|
||||
ReplyEvent::TextDelta { .. }
|
||||
| ReplyEvent::ToolActivity { .. }
|
||||
| ReplyEvent::Heartbeat => None,
|
||||
@ -123,7 +121,9 @@ mod tests {
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity { label: "lit".into() }),
|
||||
ReadinessPolicy::classify(&ReplyEvent::ToolActivity {
|
||||
label: "lit".into()
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@ -392,7 +392,7 @@ fn join_root(base: &str, rel: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::permission::{PathScope, PermissionRule, PermissionSet, resolve};
|
||||
use crate::permission::{resolve, PathScope, PermissionRule, PermissionSet};
|
||||
|
||||
// ---- helpers ---------------------------------------------------------
|
||||
|
||||
@ -668,7 +668,10 @@ mod tests {
|
||||
PathAccess::RO,
|
||||
"RW class fenced out, RO class preserved on the same root"
|
||||
);
|
||||
assert!(!g.access.contains(PathAccess::RW), "the RW grant was dropped");
|
||||
assert!(
|
||||
!g.access.contains(PathAccess::RW),
|
||||
"the RW grant was dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -163,7 +163,10 @@ mod tests {
|
||||
let plan = plan_resume(NOW, &limit(Some(past)), None);
|
||||
match plan {
|
||||
ResumePlan::Scheduled { fire_at_ms, .. } => {
|
||||
assert_eq!(fire_at_ms, NOW, "un reset déjà passé ⇒ reprise immédiate (now)");
|
||||
assert_eq!(
|
||||
fire_at_ms, NOW,
|
||||
"un reset déjà passé ⇒ reprise immédiate (now)"
|
||||
);
|
||||
}
|
||||
other => panic!("attendu Scheduled, obtenu {other:?}"),
|
||||
}
|
||||
|
||||
@ -392,6 +392,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() {
|
||||
let ctx = PreparedContext {
|
||||
content: MarkdownDoc::new("# ctx"),
|
||||
relative_path: "CLAUDE.md".to_owned(),
|
||||
project_root: "/srv/project".to_owned(),
|
||||
};
|
||||
let cwd = ProjectPath::new("/srv/run").unwrap();
|
||||
let session = factory
|
||||
|
||||
Reference in New Issue
Block a user