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:
@ -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`
|
||||
|
||||
Reference in New Issue
Block a user