chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -19,15 +19,15 @@ use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use domain::conversation::{
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
};
use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath;
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use domain::{
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
};
use crate::conversation::RecordTurn;
@ -416,7 +416,10 @@ impl OrchestratorService {
target,
content,
requester,
} => self.propose_context(project, target, content, requester).await,
} => {
self.propose_context(project, target, content, requester)
.await
}
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
@ -453,10 +456,7 @@ impl OrchestratorService {
})
.await?;
Ok(OrchestratorOutcome {
detail: format!(
"read {} context",
target.as_deref().unwrap_or("project")
),
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
reply: Some(md.into_string()),
})
}
@ -481,15 +481,17 @@ impl OrchestratorService {
})
.await?;
let detail = match outcome {
ProposeOutcome::Written => format!(
"wrote {} context",
target.as_deref().unwrap_or("project")
),
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 })
Ok(OrchestratorOutcome {
detail,
reply: None,
})
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
@ -755,8 +757,8 @@ impl OrchestratorService {
.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);
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
// 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
@ -866,8 +868,8 @@ impl OrchestratorService {
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);
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The
@ -922,6 +924,34 @@ impl OrchestratorService {
})
}
/// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
///
/// The frontend write-portal calls this (via the `delegation_delivered` Tauri
/// command) once it has **physically written** a delegation `ticket` into the agent's
/// native PTY, to distinguish "queued because the human line was busy" from "written"
/// in logs/observability. It **does not** change correlation: the requester's `ask`
/// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
/// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
/// mediator/registry never breaks the rendezvous.
pub fn note_delegation_delivered(
&self,
project: &Project,
agent_id: AgentId,
ticket: TicketId,
) {
// Observability beacon only: never mutates the mailbox/busy state nor resolves
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
// a pure, infallible hook so a missing mediator/registry never breaks the
// rendezvous. The application crate carries no logging framework (zero new dep);
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
// the orchestrator already uses for best-effort diagnostics.
let _ = project;
eprintln!(
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
(front ack, ticket {ticket})"
);
}
/// 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).
@ -944,7 +974,6 @@ impl OrchestratorService {
}
}
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
/// it is currently processing (Option 1, lot B-4).
///
@ -1039,19 +1068,18 @@ impl OrchestratorService {
})
.await?;
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"
))
})?;
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"))
AppError::Process(format!(
"handle PTY de l'agent {target} introuvable après lancement"
))
})
}
@ -1318,30 +1346,41 @@ impl OrchestratorService {
)))
}
/// 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(
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
/// pattern arms prompt detection, the submit config is echoed on the next
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
/// front applies its own `"\r"`/~60 ms default).
async fn prompt_and_submit_for_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Option<String> {
let agent = self
) -> (Option<String>, SubmitConfig) {
let Some(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)
.ok()
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
else {
return (None, SubmitConfig::default());
};
let Some(profile) = self
.profiles
.list()
.await
.ok()
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
else {
return (None, SubmitConfig::default());
};
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
(profile.prompt_ready_pattern, submit)
}
/// Resolves a human-friendly profile reference (slug like `claude-code`,