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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -1750,6 +1750,17 @@ pub(crate) fn compose_convention_file(
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
// Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche
// déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`.
// Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon
// l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas).
out.push_str(
"Quand tu reçois une tâche déléguée par IdeA (un message préfixé \
`[IdeA · tâche de … · ticket …]`), traite-la puis appelle \
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \
débloque l'agent qui t'a sollicité.\n\n",
);
} else {
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
@ -1785,6 +1796,7 @@ pub(crate) fn compose_convention_file(
out.push_str("- [");
out.push_str(&entry.title);
out.push_str("](");
out.push_str(".ideai/memory/");
out.push_str(entry.slug.as_str());
out.push_str(".md) — ");
out.push_str(&entry.hook);
@ -1998,9 +2010,13 @@ mod tests {
let section_at = doc.find("# Mémoire projet").unwrap();
assert!(persona_at < section_at, "memory comes after the persona");
// Exact line format: `- [Title](slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)"));
assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)"));
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
assert!(
doc.contains(
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
)
);
// Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap();
@ -2033,7 +2049,7 @@ mod tests {
assert!(doc.contains("# Skills"));
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("# Mémoire projet"));
assert!(doc.contains("- [Note](note.md) — a hook (project)"));
assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)"));
// Skills section precedes the memory section (persona → skills → memory).
let skills_at = doc.find("# Skills").unwrap();
@ -2066,6 +2082,25 @@ mod tests {
);
}
#[test]
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
// B-5 — the solicited-agent side of the protocol: a delegated task arrives as
// `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text.
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true);
assert!(
doc.contains("idea_reply"),
"MCP prose must instruct answering via idea_reply"
);
assert!(
doc.contains("[IdeA · tâche"),
"MCP prose must describe the delegated-task prefix it answers to"
);
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
// instruction (zero regression on the file path).
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], false);
assert!(!file_doc.contains("idea_reply"));
}
#[test]
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
@ -2087,6 +2122,60 @@ mod tests {
);
}
#[test]
fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() {
// B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the
// `.mcp.json` in its cwd (the run dir). When the composition root injects the
// real `McpRuntime`, the declaration written there must spawn *this* IdeA exe
// in `mcp-server` mode and dial the project's exact loopback endpoint — the
// same source of truth `ensure_mcp_server` binds — so the CLI can call
// `idea_list_agents` and get a reply end-to-end.
let rt = McpRuntime {
exe: "/opt/idea/idea".to_owned(),
endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(),
project_id: "0123456789abcdef0123456789abcdef".to_owned(),
requester: "11112222-3333-4444-5555-666677778888".to_owned(),
};
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
// It is valid JSON with the IdeA server under `mcpServers/idea`.
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "/opt/idea/idea");
let args = idea["args"].as_array().expect("args is an array");
let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect();
assert_eq!(args[0], "mcp-server");
// The real endpoint / project / requester are all threaded through.
assert!(args.contains(&"--endpoint"));
assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(args.contains(&"--project"));
assert!(args.contains(&"0123456789abcdef0123456789abcdef"));
assert!(args.contains(&"--requester"));
assert_eq!(idea["transport"], "stdio");
}
#[test]
fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() {
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
// command — still a valid, self-consistent `mcpServers/idea` entry.
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "idea");
let args: Vec<&str> = idea["args"]
.as_array()
.expect("args array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect();
assert_eq!(args, vec!["mcp-server"]);
// No endpoint/project/requester when no runtime was injected.
assert!(!args.contains(&"--endpoint"));
}
#[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj");

View File

@ -71,7 +71,7 @@ pub use memory::{
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use orchestrator::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,

View 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, &note).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);
}
}

View File

@ -4,6 +4,11 @@
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
//! ever spawning a process itself. See [`service::OrchestratorService`].
mod context_guard;
mod service;
pub use service::{OrchestratorOutcome, OrchestratorService};
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService};

View File

@ -19,16 +19,25 @@ use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use domain::ports::{EventBus, ProfileStore};
use domain::conversation::{
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
};
use domain::input::InputMediator;
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{EventBus, ProfileStore, PtyHandle};
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
/// resizes the PTY to the real cell size on attach; these are sane starting rows
@ -61,6 +70,19 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
/// pleins d'attente.
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
/// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint).
///
/// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin
/// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5
/// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière.
pub trait McpRuntimeProvider: Send + Sync {
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply).
/// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement).
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -71,11 +93,27 @@ pub struct OrchestratorService {
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
/// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message`
/// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via
/// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les
/// call sites/tests legacy qui n'utilisent pas la messagerie restent verts).
structured: Option<Arc<StructuredSessions>>,
/// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
/// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
/// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒
/// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la
/// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via
/// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call
/// sites/tests legacy restent verts).
input: Option<Arc<dyn InputMediator>>,
/// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head`
/// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que
/// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`].
mailbox: Option<Arc<dyn domain::mailbox::AgentMailbox>>,
/// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement
/// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes.
/// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage
/// par agent sans matérialisation de fil (legacy).
conversations: Option<Arc<dyn ConversationRegistry>>,
/// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un
/// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à
/// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock.
wait_for: StdMutex<WaitForGraph>,
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
/// publication (l'`ask` fonctionne quand même).
@ -99,6 +137,30 @@ pub struct OrchestratorService {
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
/// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration
/// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask`
/// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis
/// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`),
/// donc zéro régression pour les call sites/tests qui ne le branchent pas.
mcp_runtime_provider: Option<Arc<dyn McpRuntimeProvider>>,
/// FileGuard-mediated context/memory use cases (cadrage C7). Injected via
/// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*`
/// renvoient une erreur typée (call sites/tests legacy restent verts).
context_guard: Option<Arc<ContextGuardUseCases>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
/// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la
/// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`).
pub struct ContextGuardUseCases {
/// Lecture d'un contexte `.md` IdeA sous read-lease.
pub read_context: Arc<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
pub write_memory: Arc<WriteMemory>,
}
/// Outcome of dispatching a command — a short, human-readable success summary the
@ -137,12 +199,26 @@ impl OrchestratorService {
create_skill,
profiles,
sessions,
structured: None,
input: None,
mailbox: None,
conversations: None,
wait_for: StdMutex::new(WaitForGraph::new()),
events: None,
ask_locks: StdMutex::new(HashMap::new()),
mcp_runtime_provider: None,
context_guard: None,
}
}
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
/// inchangée).
#[must_use]
pub fn with_context_guard(mut self, guard: Arc<ContextGuardUseCases>) -> Self {
self.context_guard = Some(guard);
self
}
/// Returns the per-agent **turn lock**, creating it on first use.
///
/// Get-or-create under the synchronous map mutex (held only for this lookup,
@ -157,13 +233,30 @@ impl OrchestratorService {
Arc::clone(locks.entry(*agent_id).or_default())
}
/// Branche le registre des sessions **structurées** (§17.5) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 :
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent
/// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`.
/// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`] et
/// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de
/// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que
/// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/
/// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de
/// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts).
#[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
self.structured = Some(structured);
pub fn with_input_mediator(
mut self,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
) -> Self {
self.input = Some(input);
self.mailbox = Some(mailbox);
self
}
/// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre
/// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes.
/// Builder additif (signature de [`Self::new`] inchangée).
#[must_use]
pub fn with_conversations(mut self, conversations: Arc<dyn ConversationRegistry>) -> Self {
self.conversations = Some(conversations);
self
}
@ -175,6 +268,17 @@ impl OrchestratorService {
self
}
/// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements
/// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle**
/// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP
/// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout).
/// Builder additif : signature de [`Self::new`] **inchangée**.
#[must_use]
pub fn with_mcp_runtime_provider(mut self, provider: Arc<dyn McpRuntimeProvider>) -> Self {
self.mcp_runtime_provider = Some(provider);
self
}
/// Dispatches a validated command against `project`.
///
/// # Errors
@ -196,9 +300,16 @@ impl OrchestratorService {
self.spawn_agent(project, name, profile, context, visibility)
.await
}
OrchestratorCommand::AskAgent { target, task } => {
self.ask_agent(project, target, task).await
}
OrchestratorCommand::AskAgent {
target,
task,
requester,
} => self.ask_agent(project, target, task, requester).await,
OrchestratorCommand::Reply {
from,
ticket,
result,
} => self.reply(from, ticket, result),
OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
@ -209,9 +320,135 @@ impl OrchestratorService {
content,
scope,
} => self.create_skill(project, name, content, scope).await,
OrchestratorCommand::ReadContext { target, requester } => {
self.read_context(project, target, requester).await
}
OrchestratorCommand::ProposeContext {
target,
content,
requester,
} => self.propose_context(project, target, content, requester).await,
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
OrchestratorCommand::WriteMemory {
slug,
content,
requester,
} => self.write_memory(project, slug, content, requester).await,
}
}
/// Returns the injected C7 use cases, or a typed error when unwired.
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
self.context_guard.as_deref().ok_or_else(|| {
AppError::Invalid("FileGuard context/memory tools are not configured".to_owned())
})
}
/// `context.read` → reads an IdeA-owned context under a shared read-lease; the
/// body is returned inline in the outcome's `reply`.
async fn read_context(
&self,
project: &Project,
target: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let md = self
.require_context_guard()?
.read_context
.execute(ReadContextInput {
project: project.clone(),
target: target.clone(),
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!(
"read {} context",
target.as_deref().unwrap_or("project")
),
reply: Some(md.into_string()),
})
}
/// `context.propose` → direct write (agent ctx / orchestrator on global) or a
/// materialised proposal (non-orchestrator on global).
async fn propose_context(
&self,
project: &Project,
target: Option<String>,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let outcome = self
.require_context_guard()?
.propose_context
.execute(ProposeContextInput {
project: project.clone(),
target: target.clone(),
content,
requester,
})
.await?;
let detail = match outcome {
ProposeOutcome::Written => format!(
"wrote {} context",
target.as_deref().unwrap_or("project")
),
ProposeOutcome::Proposed { path } => {
format!("filed proposal for project context at {path}")
}
};
Ok(OrchestratorOutcome { detail, reply: None })
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
/// content is returned inline in the outcome's `reply`.
async fn read_memory(
&self,
project: &Project,
slug: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let content = self
.require_context_guard()?
.read_memory
.execute(ReadMemoryInput {
project: project.clone(),
slug: slug.clone(),
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("read memory {}", slug.as_deref().unwrap_or("index")),
reply: Some(content),
})
}
/// `memory.write` → writes a note under an exclusive write-lease.
async fn write_memory(
&self,
project: &Project,
slug: String,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
self.require_context_guard()?
.write_memory
.execute(WriteMemoryInput {
project: project.clone(),
slug: slug.clone(),
content,
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("wrote memory {slug}"),
reply: None,
})
}
/// `spawn_agent`: create the agent if the manifest doesn't already hold one by
/// that name, then launch it (which publishes `AgentLaunched` → the UI opens a
/// cell + the Agents tab).
@ -328,88 +565,346 @@ impl OrchestratorService {
})
}
/// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4).
/// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous**
/// (Option 1 « Terminal + MCP », lot B-3).
///
/// Resolves the target by name, ensures it has a **live structured session**
/// (launching it in structured mode if needed), sends `task` and **waits for the
/// turn's `Final`** via [`send_blocking`], then returns its content as
/// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`].
/// The target's human-facing view is now a **raw native terminal** (PTY REPL), and
/// delegation flows through the terminal's single FIFO input plus the MCP mailbox:
///
/// Invariants:
/// - **1 session vivante/agent** : on réutilise la session structurée vivante si
/// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée.
/// - **Timeout ne tue pas la session** : [`send_blocking`] remonte
/// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en
/// laissant la session vivante dans le registre (retry possible).
/// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en
/// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur.
/// 1. Resolve the target by name and acquire its **per-agent turn lock** so two
/// `ask`s for the same target serialise FIFO (1 agent = 1 employee).
/// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if
/// it is already running, otherwise launching it in the background (a normal
/// PTH launch: a live PTY *is* the channel now, not an error as before).
/// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot)
/// **then write** the task into the target's terminal, prefixed with the asking
/// agent + ticket id so the target knows to answer via `idea_reply`.
/// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]:
/// the target's later `idea_reply(result)` lands in [`Self::reply`] →
/// `mailbox.resolve`, waking this await. On timeout the ticket is retired from
/// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a
/// typed timeout is returned (retry possible).
/// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish
/// [`DomainEvent::AgentReplied`].
///
/// # Errors
/// - [`AppError::NotFound`] si l'agent cible est inconnu ;
/// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la
/// cible n'est pas pilotable en mode structuré ;
/// - [`AppError::Process`] sur échec/timeout du tour structuré.
/// - [`AppError::NotFound`] if the target agent is unknown;
/// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired;
/// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout
/// (turn timeout *or* queue-wait timeout — same typed error).
async fn ask_agent(
&self,
project: &Project,
target: String,
task: String,
requester: Option<AgentId>,
) -> Result<OrchestratorOutcome, AppError> {
let structured = self.structured.as_ref().ok_or_else(|| {
AppError::Invalid(
"la messagerie inter-agents (agent.message) n'est pas disponible : \
registre des sessions structurées non câblé"
.to_owned(),
)
})?;
let (input, mailbox) = match (&self.input, &self.mailbox) {
(Some(i), Some(m)) => (i, m),
_ => {
return Err(AppError::Invalid(
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
))
}
};
let agent_id = self
.find_agent_id_by_name(project, &target)
let agent = self
.find_agent_by_name(project, &target)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
let agent_id = agent.id;
// Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte).
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors.
//
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ;
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock.
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas**
// l'attente du verrou ; cette attente a son **propre** plafond
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition.
// F2 — garde profil : refuser **immédiatement** une cible dont le profil ne
// sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que
// de laisser le round-trip échouer en timeout muet (300s).
self.guard_mcp_bridge_supported(&agent.profile_id, &target)
.await?;
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
// cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
// d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
if let Some(from) = requester {
let cycles = {
let g = self
.wait_for
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.would_cycle(from, agent_id)
};
if cycles {
return Err(AppError::Invalid(format!(
"délégation ré-entrante refusée : demander à l'agent '{target}' créerait \
un cycle d'attente inter-agents (deadlock évité)"
)));
}
}
// Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon
// User↔B. La session vivante est désormais keyée par conversation (lève
// `session-registry-agent-ambiguity`).
let conversation_id = self.resolve_conversation(requester, agent_id);
// Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
// pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
let lock = self.ask_lock_for(&agent_id);
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
Ok(guard) => guard,
Err(_elapsed) => {
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) :
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
return Err(AppError::from(
domain::ports::AgentSessionError::Timeout,
));
return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
}
};
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct.
if let Some(session) = structured.session_for_agent(&agent_id) {
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
return Ok(self.reply_outcome(agent_id, &target, content));
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
let handle = self
.ensure_live_pty(project, agent_id, conversation_id, &target)
.await?;
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
// porte la source (Human/Agent) et la conversation cible.
let requester_label = self.requester_label(project, requester).await;
let ticket_id = TicketId::new_random();
let ticket = match requester {
Some(from) => {
Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task)
}
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
};
let pending = input.enqueue(agent_id, ticket);
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
// turn into the bound handle). The service no longer writes the PTY directly —
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
// (cible laissée vivante) et renvoyer une erreur typée.
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
Ok(Ok(result)) => Ok(self.reply_outcome(agent_id, &target, result)),
Ok(Err(_cancelled)) => {
mailbox.cancel_head(agent_id, ticket_id);
Err(AppError::Process(format!(
"agent {target} : canal de réponse fermé avant un résultat"
)))
}
Err(_elapsed) => {
mailbox.cancel_head(agent_id, ticket_id);
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
}
}
}
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
///
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
/// thread, ensures the target is live in the PTY registry, binds its handle on the
/// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the
/// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a
/// delegation serialise on the same agent («1 agent = 1 employee»).
///
/// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the
/// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply
/// slot is registered and simply left to resolve/expire on its own). The busy
/// event is emitted at the mediator's source (Idle→Busy on the starting enqueue).
///
/// # Errors
/// - [`AppError::Invalid`] if the input mediator is not wired;
/// - [`AppError::NotFound`] if the target agent is unknown;
/// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session.
pub async fn submit_human_input(
&self,
project: &Project,
agent_id: AgentId,
text: String,
) -> Result<OrchestratorOutcome, AppError> {
let input = self.input.as_ref().ok_or_else(|| {
AppError::Invalid(
"l'entrée médiée (submit_agent_input) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
)
})?;
// Display label for error/launch messages; the agent must exist in the
// manifest. A human submit to an unknown id is a NotFound, never a panic.
let target = self
.find_name_by_agent_id(project, agent_id)
.await
.ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?;
let target = target.as_str();
// User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask.
let conversation_id = self.resolve_conversation(None, agent_id);
// Ensure the target is live for this thread and bind its input handle on the
// mediator (delivery path). Same call the ask path uses.
let handle = self
.ensure_live_pty(project, agent_id, conversation_id, target)
.await?;
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The
// mediator emits AgentBusyChanged at the source on a starting turn.
let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text);
let _pending = input.enqueue(agent_id, ticket);
Ok(OrchestratorOutcome {
detail: format!("submitted human input to agent {target}"),
reply: None,
})
}
/// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop).
///
/// Resolves the target by name and calls [`InputMediator::preempt`], which signals
/// the running turn to stop (best-effort interrupt byte to the agent's bound PTY
/// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller
/// is never silently answered. Idempotent: interrupting an idle agent is a no-op.
///
/// # Errors
/// - [`AppError::Invalid`] if the input mediator is not wired;
/// - [`AppError::NotFound`] if the target agent is unknown.
pub async fn interrupt_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<OrchestratorOutcome, AppError> {
let input = self.input.as_ref().ok_or_else(|| {
AppError::Invalid(
"l'interruption (interrupt_agent) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
)
})?;
// Confirm the agent exists (typed NotFound rather than a silent no-op on a
// bogus id). The manifest lookup also keeps the contract symmetric with submit.
if self
.find_name_by_agent_id(project, agent_id)
.await
.is_none()
{
return Err(AppError::NotFound(format!("agent {agent_id}")));
}
// 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal
// brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas
// d'ACK « launched »).
if self.sessions.session_for_agent(&agent_id).is_some() {
return Err(AppError::Invalid(format!(
"agent {target} n'est pas pilotable en mode structuré \
(session terminal brut, pas de canal de réponse)"
)));
input.preempt(agent_id);
Ok(OrchestratorOutcome {
detail: format!("interrupted agent {agent_id}"),
reply: None,
})
}
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
/// stable per-agent id derived from the target (legacy routing — never panics).
fn resolve_conversation(
&self,
requester: Option<AgentId>,
target: AgentId,
) -> domain::conversation::ConversationId {
let left = match requester {
Some(from) => ConversationParty::agent(from),
None => ConversationParty::User,
};
let right = ConversationParty::agent(target);
match &self.conversations {
Some(reg) => reg.resolve(left, right).id,
None => domain::conversation::ConversationId::from_uuid(target.as_uuid()),
}
}
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
/// it is currently processing (Option 1, lot B-4).
///
/// Positional correlation: `from` is the **emitting** agent (its identity comes
/// from the MCP handshake, not from a model-managed id), so the result resolves
/// the ticket at the **head** of *that agent's* mailbox queue — the task it is
/// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs
/// to the asking side's `ask_agent`).
///
/// # Errors
/// - [`AppError::Invalid`] if the mailbox is not wired;
/// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no
/// matching ask) — typed, never a panic.
fn reply(
&self,
from: AgentId,
ticket: Option<TicketId>,
result: String,
) -> Result<OrchestratorOutcome, AppError> {
let mailbox = self.mailbox.as_ref().ok_or_else(|| {
AppError::Invalid(
"idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(),
)
})?;
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
match ticket {
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
None => mailbox.resolve(from, result),
}
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
// advances to the next queued ticket. This is the deterministic OR signal that
// pairs with prompt-ready detection; whichever fires first frees the turn. No-op
// (and no spurious event) when the mediator is absent or `from` was already idle.
if let Some(input) = self.input.as_ref() {
input.mark_idle(from);
}
Ok(OrchestratorOutcome {
detail: format!("reply from agent {from} delivered"),
reply: None,
})
}
/// Ensures the target agent has a **live PTY session**, returning its handle.
///
/// Reuses the running terminal when present; otherwise launches the agent in the
/// background (a normal PTH launch — a live PTY is the delegation channel). After
/// a launch the handle is resolved from the registry; a missing handle is a
/// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
/// IdeA cannot drive as a terminal).
async fn ensure_live_pty(
&self,
project: &Project,
agent_id: AgentId,
conversation_id: domain::conversation::ConversationId,
target: &str,
) -> Result<PtyHandle, AppError> {
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
let existing = self
.sessions
.session_for(conversation_id)
.or_else(|| self.sessions.session_for_agent(&agent_id));
if let Some(session_id) = existing {
if let Some(handle) = self.sessions.handle(&session_id) {
// (Re)lier le fil à cette session vivante (idempotent).
self.bind_conversation_session(conversation_id, session_id);
return Ok(handle);
}
}
// 3. Cible morte : on la lance en mode structuré (background) puis on envoie.
let launched = self
.launch_agent
// Dead target: launch it in the background (PTY). On injecte ici la déclaration
// MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
// l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc
// à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`)
// ⇒ déclaration minimale comme avant (dégradation gracieuse).
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
@ -417,30 +912,71 @@ impl OrchestratorService {
cols: DEFAULT_COLS,
node_id: None,
conversation_id: None,
// ask_agent revival launch (inside `application`): MCP runtime not
// injected here (see the `spawn_agent` note above).
mcp_runtime: None,
mcp_runtime: self
.mcp_runtime_provider
.as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
})
.await?;
// Si le lancement n'a pas produit de session structurée, le profil de la cible
// est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`.
if launched.structured.is_none() {
return Err(AppError::Invalid(format!(
"agent {target} n'est pas pilotable en mode structuré \
(profil sans adaptateur structuré)"
)));
}
let session_id = self
.sessions
.session_for_agent(&agent_id)
.ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
self.bind_conversation_session(conversation_id, session_id);
self.sessions.handle(&session_id).ok_or_else(|| {
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
})
}
// La session vient d'être enregistrée par LaunchAgent : on la récupère par
// agent (invariant « 1 session/agent » ⇒ non ambigu).
let session = structured.session_for_agent(&agent_id).ok_or_else(|| {
AppError::Process(format!(
"session structurée de l'agent {target} introuvable après lancement"
))
})?;
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
Ok(self.reply_outcome(agent_id, &target, content))
/// Binds `conversation` to `session` in both the terminal registry (fast
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
/// latter is wired. Idempotent.
fn bind_conversation_session(
&self,
conversation: domain::conversation::ConversationId,
session: domain::SessionId,
) {
self.sessions.bind_conversation(conversation, session);
if let Some(reg) = &self.conversations {
reg.bind_session(conversation, SessionRef::new(session));
}
}
/// Resolves a human-friendly label for the **requesting** agent to prefix the
/// delegated task with. Best-effort: there is no requester id threaded into
/// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable
/// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can
/// surface the real name without touching the call sites.
async fn requester_label(&self, project: &Project, requester: Option<AgentId>) -> String {
match requester {
None => "un autre agent".to_owned(),
Some(from) => self
.find_name_by_agent_id(project, from)
.await
.unwrap_or_else(|| "un autre agent".to_owned()),
}
}
/// Best-effort display name for an agent id (manifest lookup). `None` when the id
/// is unknown — the caller falls back to a generic label.
async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option<String> {
self.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
.ok()?
.agents
.into_iter()
.find(|a| a.id == id)
.map(|a| a.name)
}
/// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
@ -590,6 +1126,104 @@ impl OrchestratorService {
.map(|a| a.id))
}
/// Finds the full [`domain::Agent`] by display name (case-insensitive) in the
/// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve
/// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2.
async fn find_agent_by_name(
&self,
project: &Project,
name: &str,
) -> Result<Option<domain::Agent>, AppError> {
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
Ok(listed
.agents
.into_iter()
.find(|a| a.name.eq_ignore_ascii_case(name)))
}
/// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont
/// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`]
/// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip).
///
/// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le
/// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json`
/// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le
/// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul**
/// Claude Code lit réellement ; Codex déclare pourtant la même stratégie
/// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont
/// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc
/// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui
/// exclut Codex de fait et reste valable pour tout futur profil non-Claude.
///
/// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours
/// comme avant) : la garde ne fait que transformer un échec connu en erreur typée.
async fn guard_mcp_bridge_supported(
&self,
profile_id: &ProfileId,
target: &str,
) -> Result<(), AppError> {
use domain::profile::{McpConfigStrategy, StructuredAdapter};
let Some(profile) = self
.profiles
.list()
.await?
.into_iter()
.find(|p| &p.id == profile_id)
else {
return Ok(());
};
let honours_mcp_json = matches!(
profile.mcp.as_ref().map(|c| &c.config),
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
);
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
if honours_mcp_json && is_claude {
return Ok(());
}
Err(AppError::Invalid(format!(
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
profil Claude.",
profile.name, profile.structured_adapter
)))
}
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
async fn prompt_pattern_for_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Option<String> {
let agent = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
.ok()?
.agents
.into_iter()
.find(|a| a.id == agent_id)?;
let profiles = self.profiles.list().await.ok()?;
profiles
.into_iter()
.find(|p| p.id == agent.profile_id)
.and_then(|p| p.prompt_ready_pattern)
}
/// Resolves a human-friendly profile reference (slug like `claude-code`,
/// command like `claude`, or display name like `Claude Code`) to a configured
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
@ -612,6 +1246,44 @@ impl OrchestratorService {
}
}
/// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and
/// removes it on drop (reply, timeout, or any early return) — the same discipline as
/// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on
/// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the
/// service outlives the guard.
struct WaitEdgeGuard<'a> {
graph: &'a StdMutex<WaitForGraph>,
from: AgentId,
to: AgentId,
}
impl<'a> WaitEdgeGuard<'a> {
fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self {
{
let mut g = service
.wait_for
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.add_edge(from, to);
}
Self {
graph: &service.wait_for,
from,
to,
}
}
}
impl Drop for WaitEdgeGuard<'_> {
fn drop(&mut self) {
let mut g = self
.graph
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.remove_edge(self.from, self.to);
}
}
/// Normalises a profile reference for tolerant matching: lowercased, with spaces,
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
/// → comparable forms; `claude` ⊂ ... handled by the command match above).

View File

@ -9,6 +9,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
@ -44,6 +45,11 @@ pub trait LiveAgentRegistry: Send + Sync {
#[derive(Default)]
pub struct TerminalSessions {
entries: Mutex<HashMap<SessionId, Entry>>,
/// Conversation → live session binding (cadrage C3 §5.2): «1 session vivante /
/// **conversation**» (remplace «1 / agent»). Populated by the orchestrator when an
/// ask resolves/launches the session for a given thread. Separate from `entries`
/// (the domain [`TerminalSession`] does not carry a conversation id).
conversations: Mutex<HashMap<ConversationId, SessionId>>,
}
impl LiveAgentRegistry for TerminalSessions {
@ -65,9 +71,50 @@ impl TerminalSessions {
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
conversations: Mutex::new(HashMap::new()),
}
}
/// Binds `conversation` to the live `session` (cadrage C3 §5.2). Idempotent:
/// re-binding the same conversation overwrites the target session.
pub fn bind_conversation(&self, conversation: ConversationId, session: SessionId) {
if let Ok(mut m) = self.conversations.lock() {
m.insert(conversation, session);
}
}
/// Returns the live [`SessionId`] bound to `conversation`, if any **and** still
/// registered (a stale binding to a closed session resolves to `None`).
///
/// «1 session vivante / conversation» — deterministic, replacing the ambiguous
/// per-agent lookup for the orchestrator's ask path.
#[must_use]
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
// Only return it if the session is still live in `entries`.
self.entries
.lock()
.ok()
.filter(|m| m.contains_key(&sid))
.map(|_| sid)
}
/// Lists every live [`SessionId`] hosting `agent_id` (cadrage C3 §1.2 — an agent
/// may take part in several threads, so this is the **plural** of
/// [`Self::session_for_agent`]).
#[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
.collect()
})
.unwrap_or_default()
}
/// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
if let Ok(mut map) = self.entries.lock() {
@ -184,8 +231,12 @@ impl TerminalSessions {
.unwrap_or_default()
}
/// Removes a session from the registry, returning its handle if present.
/// Removes a session from the registry, returning its handle if present. Also
/// drops any conversation binding pointing at it (no stale `session_for`).
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
if let Ok(mut c) = self.conversations.lock() {
c.retain(|_, sid| sid != id);
}
self.entries
.lock()
.ok()