feat(infrastructure): store, sink de complétion et mailbox des tâches de fond (B2-B4)

Adapters de persistance des BackgroundTask (store), sink de récupération
de complétion post-tour et boîte de réception (mailbox) pour les messages
concurrents, câblés sur l'entrée. Couvert par background_task_store,
background_completion_sink, agent_inbox et orchestrator_watcher (verts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:47:28 +02:00
parent f4a55e9988
commit c537da54ef
9 changed files with 1889 additions and 67 deletions

View File

@ -16,15 +16,15 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::NodeId;
use domain::ids::SkillId;
use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
BackgroundTaskPortError, BackgroundTaskStore, Clock, ContextInjectionPlan, DirEntry, EventBus,
EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext,
ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent, ReplyStream, RuntimeError,
SessionPlan, SkillStore, SpawnSpec, StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
@ -33,13 +33,14 @@ use domain::profile::{
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use domain::{
BackgroundTask, BackgroundTaskResult, BackgroundTaskState, PtySize, SessionId, TaskId,
};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, TerminalSessions, UpdateAgentContext,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse,
@ -316,6 +317,172 @@ impl IdGenerator for SeqIds {
}
}
struct FixedClock(i64);
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
self.0
}
}
#[derive(Default)]
struct InMemoryBackgroundTaskStore {
tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
}
impl InMemoryBackgroundTaskStore {
fn all(&self) -> Vec<BackgroundTask> {
self.tasks.lock().unwrap().values().cloned().collect()
}
}
#[async_trait]
impl BackgroundTaskStore for InMemoryBackgroundTaskStore {
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
if tasks.contains_key(&task.id) {
return Err(BackgroundTaskPortError::AlreadyExists);
}
tasks.insert(task.id, task.clone());
Ok(())
}
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
Ok(self.tasks.lock().unwrap().get(&id).cloned())
}
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
self.tasks.lock().unwrap().insert(task.id, task.clone());
Ok(())
}
async fn list_open_for_agent(
&self,
agent_id: AgentId,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
.cloned()
.collect())
}
async fn list_undelivered_completions(
&self,
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
Ok(self
.tasks
.lock()
.unwrap()
.values()
.filter(|task| task.has_pending_completion_delivery())
.cloned()
.collect())
}
async fn mark_completion_delivered(
&self,
task_id: TaskId,
) -> Result<(), BackgroundTaskPortError> {
let mut tasks = self.tasks.lock().unwrap();
let task = tasks
.get(&task_id)
.cloned()
.ok_or(BackgroundTaskPortError::NotFound)?;
let delivered = task
.mark_completion_delivered()
.map_err(|err| BackgroundTaskPortError::Invalid(err.to_string()))?;
tasks.insert(task_id, delivered);
Ok(())
}
}
#[derive(Clone)]
struct BlockingReplyFactory {
outcome: Arc<Mutex<BlockingReplyOutcome>>,
notify: Arc<tokio::sync::Notify>,
}
impl BlockingReplyFactory {
fn new(reply: impl Into<String>) -> Self {
Self {
outcome: Arc::new(Mutex::new(BlockingReplyOutcome::Final(reply.into()))),
notify: Arc::new(tokio::sync::Notify::new()),
}
}
fn no_reply(&self) {
*self.outcome.lock().unwrap() = BlockingReplyOutcome::NoReply;
}
fn release(&self) {
self.notify.notify_waiters();
}
}
#[derive(Clone)]
enum BlockingReplyOutcome {
Final(String),
NoReply,
}
struct BlockingReplySession {
id: SessionId,
outcome: Arc<Mutex<BlockingReplyOutcome>>,
notify: Arc<tokio::sync::Notify>,
}
#[async_trait]
impl AgentSession for BlockingReplySession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.notify.notified().await;
match self.outcome.lock().unwrap().clone() {
BlockingReplyOutcome::Final(content) => {
Ok(Box::new(vec![ReplyEvent::Final { content }].into_iter()))
}
BlockingReplyOutcome::NoReply => Err(AgentSessionError::Io(
"target returned without a structured final".to_owned(),
)),
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[async_trait]
impl AgentSessionFactory for BlockingReplyFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
profile.structured_adapter.is_some()
}
async fn start(
&self,
_profile: &AgentProfile,
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(BlockingReplySession {
id: SessionId::from_uuid(Uuid::new_v4()),
outcome: Arc::clone(&self.outcome),
notify: Arc::clone(&self.notify),
}))
}
}
fn project() -> Project {
Project::new(
ProjectId::from_uuid(Uuid::from_u128(1000)),
@ -396,6 +563,8 @@ fn build_service_with_mailbox(
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
BlockingReplyFactory,
Arc<InMemoryBackgroundTaskStore>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
@ -417,6 +586,9 @@ fn build_service_with_mailbox(
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let structured_factory = BlockingReplyFactory::new("the answer is 42");
let background_tasks = Arc::new(InMemoryBackgroundTaskStore::default());
let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -424,19 +596,25 @@ fn build_service_with_mailbox(
Arc::new(SeqIds(Mutex::new(1))),
bus.clone(),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let launch = Arc::new(
LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(structured_factory.clone()) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
@ -466,23 +644,20 @@ fn build_service_with_mailbox(
.with_conversations(
Arc::new(infrastructure::InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>,
)
.with_structured(Arc::clone(&structured))
.with_background_tasks(
Arc::clone(&background_tasks) as Arc<dyn BackgroundTaskStore>,
Arc::new(FixedClock(1_700_000_000_000)) as Arc<dyn Clock>,
),
);
(service, mailbox, sessions)
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
(
service,
mailbox,
sessions,
structured_factory,
background_tasks,
)
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
@ -637,21 +812,15 @@ async fn unknown_action_yields_error_response() {
/// alongside `ok: true`.
#[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() {
// Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's
// `idea_reply`. Over the file protocol we drive the ask request on a task, wait
// for the mailbox to hold a ticket, then process a separate `agent.reply` request
// (carrying the target's id as `requestedBy`, the handshake identity) which
// resolves it. The ask's `*.response.json` then carries reply + detail.
// B6: an `agent.message` request is still synchronous for the caller, but the
// target turn is driven through the structured/headless session. The mailbox still
// receives exactly one silent ticket for turn accounting; the response comes from
// `ReplyEvent::Final`, not from a separate `agent.reply` request.
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
// Target already live in the PTY registry, so the ask reuses its terminal.
seed_live_pty(
&sessions,
agent_id,
SessionId::from_uuid(Uuid::from_u128(4242)),
);
let (service, mailbox, _sessions, structured_reply, tasks) =
build_service_with_mailbox(contexts.clone());
let req = tmp.0.join("ask-req.json");
std::fs::write(
@ -674,19 +843,7 @@ async fn ask_request_surfaces_reply_alongside_detail() {
.await
.expect("ask must enqueue a ticket");
// The target renders its result via an `agent.reply` request whose `requestedBy`
// is its own id (the handshake identity the MCP server would inject as `from`).
let reply_req = tmp.0.join("reply-req.json");
std::fs::write(
&reply_req,
format!(
r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"#
)
.as_bytes(),
)
.unwrap();
let reply_resp = process_request_file(&reply_req, &project(), &service).await;
assert!(reply_resp.ok, "reply request ok: {reply_resp:?}");
structured_reply.release();
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
@ -718,6 +875,74 @@ async fn ask_request_surfaces_reply_alongside_detail() {
assert_eq!(json["reply"], serde_json::json!("the answer is 42"));
assert!(json.get("detail").is_some());
assert!(!req.exists(), "request file must be removed");
let stored = tasks.all();
assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
let task = &stored[0];
assert_eq!(task.owner_agent_id, agent_id);
assert_eq!(task.state, BackgroundTaskState::Completed);
assert!(matches!(
task.kind,
domain::BackgroundTaskKind::HeadlessRendezvous { .. }
));
assert!(matches!(
task.result,
Some(BackgroundTaskResult::Success { .. })
));
}
#[tokio::test]
async fn ask_request_no_reply_persists_failed_rendezvous_task() {
let tmp = TempDir::new();
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, mailbox, _sessions, structured_reply, tasks) =
build_service_with_mailbox(contexts.clone());
structured_reply.no_reply();
let req = tmp.0.join("ask-no-reply.json");
std::fs::write(
&req,
br#"{ "type": "agent.message", "requestedBy": "Main", "targetAgent": "architect", "task": "Please forget to finalise" }"#,
)
.unwrap();
let svc = Arc::clone(&service);
let proj = project();
let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket before no-reply");
structured_reply.release();
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after no-reply")
.expect("join ok");
assert!(!response.ok, "expected no-reply error, got {response:?}");
let stored = tasks.all();
assert_eq!(stored.len(), 1, "one rendezvous task persisted: {stored:?}");
let task = &stored[0];
assert_eq!(task.owner_agent_id, agent_id);
assert_eq!(task.state, BackgroundTaskState::Failed);
match task.result.as_ref() {
Some(BackgroundTaskResult::Failure { error, .. }) => {
assert!(
error.contains("NoReply"),
"error should classify no-reply: {error}"
);
}
other => panic!("expected failure result, got {other:?}"),
}
assert_eq!(mailbox.pending(&agent_id), 0, "turn-lock mailbox is freed");
}
/// Point 2 — a non-`ask` command (here `spawn_agent`, reply `None`) ⇒ the `reply`