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